pecca 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.
Files changed (162) hide show
  1. pecca/__init__.py +38 -0
  2. pecca/api.py +406 -0
  3. pecca/audit/__init__.py +3 -0
  4. pecca/audit/pack.py +110 -0
  5. pecca/audit/renderer.py +29 -0
  6. pecca/audit/sections/__init__.py +28 -0
  7. pecca/audit/sections/operations.py +24 -0
  8. pecca/audit/sections/performance.py +66 -0
  9. pecca/audit/signing.py +48 -0
  10. pecca/candidates/__init__.py +19 -0
  11. pecca/candidates/base.py +184 -0
  12. pecca/candidates/classical.py +49 -0
  13. pecca/candidates/embeddings.py +162 -0
  14. pecca/candidates/registry.py +127 -0
  15. pecca/candidates/tabular.py +74 -0
  16. pecca/candidates/transformers_ft.py +156 -0
  17. pecca/cli/__init__.py +0 -0
  18. pecca/cli/commands/__init__.py +0 -0
  19. pecca/cli/commands/ops.py +92 -0
  20. pecca/cli/commands/pipeline.py +384 -0
  21. pecca/cli/commands/setup.py +202 -0
  22. pecca/cli/common.py +39 -0
  23. pecca/cli/main.py +47 -0
  24. pecca/config/__init__.py +0 -0
  25. pecca/config/apply.py +42 -0
  26. pecca/config/loader.py +87 -0
  27. pecca/config/resolve.py +82 -0
  28. pecca/config/schema.json +62 -0
  29. pecca/connectors/__init__.py +5 -0
  30. pecca/connectors/_mcp.py +76 -0
  31. pecca/connectors/base.py +207 -0
  32. pecca/connectors/datasource/__init__.py +0 -0
  33. pecca/connectors/datasource/_base.py +47 -0
  34. pecca/connectors/datasource/_chat.py +56 -0
  35. pecca/connectors/datasource/_sql.py +49 -0
  36. pecca/connectors/datasource/bigquery.py +28 -0
  37. pecca/connectors/datasource/csv.py +24 -0
  38. pecca/connectors/datasource/databricks.py +44 -0
  39. pecca/connectors/datasource/databricks_ai_gateway.py +50 -0
  40. pecca/connectors/datasource/litellm_logs.py +57 -0
  41. pecca/connectors/datasource/mcp.py +35 -0
  42. pecca/connectors/datasource/otel_traces.py +117 -0
  43. pecca/connectors/datasource/parquet.py +24 -0
  44. pecca/connectors/datasource/postgres.py +45 -0
  45. pecca/connectors/datasource/snowflake.py +58 -0
  46. pecca/connectors/docs_publisher/__init__.py +0 -0
  47. pecca/connectors/docs_publisher/confluence.py +65 -0
  48. pecca/connectors/docs_publisher/markdown.py +19 -0
  49. pecca/connectors/identity/__init__.py +0 -0
  50. pecca/connectors/identity/oidc.py +39 -0
  51. pecca/connectors/notifier/__init__.py +0 -0
  52. pecca/connectors/notifier/mcp.py +23 -0
  53. pecca/connectors/notifier/slack.py +24 -0
  54. pecca/connectors/notifier/teams.py +36 -0
  55. pecca/connectors/notifier/webhook.py +31 -0
  56. pecca/connectors/registry/__init__.py +0 -0
  57. pecca/connectors/registry/_pyfunc.py +28 -0
  58. pecca/connectors/registry/local.py +110 -0
  59. pecca/connectors/registry/mlflow.py +260 -0
  60. pecca/connectors/registry/wandb.py +159 -0
  61. pecca/connectors/scheduler/__init__.py +0 -0
  62. pecca/connectors/scheduler/airflow.py +54 -0
  63. pecca/connectors/scheduler/cron.py +46 -0
  64. pecca/connectors/scheduler/databricks_workflows.py +67 -0
  65. pecca/connectors/secrets/__init__.py +0 -0
  66. pecca/connectors/secrets/aws_secrets_manager.py +35 -0
  67. pecca/connectors/secrets/env.py +20 -0
  68. pecca/connectors/secrets/vault.py +43 -0
  69. pecca/connectors/serving/__init__.py +0 -0
  70. pecca/connectors/serving/databricks_serving.py +49 -0
  71. pecca/connectors/serving/docker_export.py +74 -0
  72. pecca/connectors/serving/onnx_local.py +21 -0
  73. pecca/connectors/ticketer/__init__.py +0 -0
  74. pecca/connectors/ticketer/github_issues.py +62 -0
  75. pecca/connectors/ticketer/jira.py +117 -0
  76. pecca/connectors/ticketer/manual.py +61 -0
  77. pecca/connectors/ticketer/mcp.py +54 -0
  78. pecca/core/__init__.py +0 -0
  79. pecca/core/call.py +108 -0
  80. pecca/core/context.py +62 -0
  81. pecca/core/errors.py +34 -0
  82. pecca/core/models.py +126 -0
  83. pecca/core/paths.py +36 -0
  84. pecca/core/project.py +44 -0
  85. pecca/core/timeutil.py +45 -0
  86. pecca/core/workspace.py +78 -0
  87. pecca/data/__init__.py +0 -0
  88. pecca/data/dataset.py +205 -0
  89. pecca/data/labels.py +29 -0
  90. pecca/data/synthetic.py +630 -0
  91. pecca/evaluation.py +141 -0
  92. pecca/governance/__init__.py +3 -0
  93. pecca/governance/approvals.py +57 -0
  94. pecca/governance/engine.py +37 -0
  95. pecca/governance/evidence.py +27 -0
  96. pecca/governance/gates.py +79 -0
  97. pecca/governance/retention.py +18 -0
  98. pecca/governance/schedule.py +38 -0
  99. pecca/governance/schema.json +54 -0
  100. pecca/integrations/__init__.py +1 -0
  101. pecca/integrations/_common.py +23 -0
  102. pecca/integrations/claude_agent.py +26 -0
  103. pecca/integrations/google_adk.py +15 -0
  104. pecca/integrations/langgraph.py +15 -0
  105. pecca/integrations/openai_agents.py +15 -0
  106. pecca/integrations/strands.py +15 -0
  107. pecca/mcp/__init__.py +0 -0
  108. pecca/mcp/server.py +116 -0
  109. pecca/profiler/__init__.py +3 -0
  110. pecca/profiler/profiler.py +78 -0
  111. pecca/profiler/rules.py +89 -0
  112. pecca/profiles/README.md +11 -0
  113. pecca/profiles/bsp.yaml +17 -0
  114. pecca/profiles/cbuae.yaml +17 -0
  115. pecca/profiles/default.yaml +14 -0
  116. pecca/profiles/eu-ai-act.yaml +17 -0
  117. pecca/profiles/none.yaml +10 -0
  118. pecca/profiles/rbi.yaml +17 -0
  119. pecca/profiles/sarb.yaml +17 -0
  120. pecca/profiles/sr11-7.yaml +17 -0
  121. pecca/runtime/__init__.py +3 -0
  122. pecca/runtime/decorator.py +217 -0
  123. pecca/runtime/fallback.py +21 -0
  124. pecca/runtime/logging_sink.py +84 -0
  125. pecca/runtime/predictor.py +119 -0
  126. pecca/runtime/telemetry.py +41 -0
  127. pecca/templates/__init__.py +0 -0
  128. pecca/templates/approval_ticket.md.j2 +14 -0
  129. pecca/templates/audit_pack/__init__.py +0 -0
  130. pecca/templates/audit_pack/approvals.md.j2 +11 -0
  131. pecca/templates/audit_pack/benchmark_leaderboard.md.j2 +7 -0
  132. pecca/templates/audit_pack/calibration.md.j2 +11 -0
  133. pecca/templates/audit_pack/confusion_matrix.md.j2 +8 -0
  134. pecca/templates/audit_pack/controls_mapping.md.j2 +8 -0
  135. pecca/templates/audit_pack/data_lineage.md.j2 +11 -0
  136. pecca/templates/audit_pack/drift_report.md.j2 +4 -0
  137. pecca/templates/audit_pack/environment.md.j2 +8 -0
  138. pecca/templates/audit_pack/human_oversight_log.md.j2 +9 -0
  139. pecca/templates/audit_pack/manifest.md.j2 +6 -0
  140. pecca/templates/audit_pack/model_card.md.j2 +1 -0
  141. pecca/templates/audit_pack/per_class_report.md.j2 +7 -0
  142. pecca/templates/audit_pack/shadow_results.md.j2 +9 -0
  143. pecca/templates/audit_pack/summary.md.j2 +12 -0
  144. pecca/templates/audit_pack/threshold_rationale.md.j2 +13 -0
  145. pecca/templates/confluence_page.html.j2 +3 -0
  146. pecca/templates/model_card.md.j2 +12 -0
  147. pecca/templates/slack_event.md.j2 +2 -0
  148. pecca/templates_util.py +41 -0
  149. pecca/trainer/__init__.py +3 -0
  150. pecca/trainer/calibration.py +84 -0
  151. pecca/trainer/export_onnx.py +42 -0
  152. pecca/trainer/lineage.py +72 -0
  153. pecca/trainer/metrics.py +73 -0
  154. pecca/trainer/splits.py +42 -0
  155. pecca/trainer/threshold.py +37 -0
  156. pecca/trainer/tournament.py +259 -0
  157. pecca-0.1.0.dist-info/METADATA +181 -0
  158. pecca-0.1.0.dist-info/RECORD +162 -0
  159. pecca-0.1.0.dist-info/WHEEL +4 -0
  160. pecca-0.1.0.dist-info/entry_points.txt +2 -0
  161. pecca-0.1.0.dist-info/licenses/LICENSE +191 -0
  162. pecca-0.1.0.dist-info/licenses/NOTICE +4 -0
pecca/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Pecca: replace repetitive LLM calls with small, auditable models."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from pecca import connectors # noqa: E402
6
+ from pecca.api import ( # noqa: E402
7
+ approve,
8
+ as_tool,
9
+ audit_pack,
10
+ connect,
11
+ evaluate,
12
+ load,
13
+ predict,
14
+ profile,
15
+ promote,
16
+ register,
17
+ replace,
18
+ retrain,
19
+ train,
20
+ )
21
+
22
+ __all__ = [
23
+ "__version__",
24
+ "approve",
25
+ "as_tool",
26
+ "audit_pack",
27
+ "connect",
28
+ "connectors",
29
+ "evaluate",
30
+ "load",
31
+ "predict",
32
+ "profile",
33
+ "promote",
34
+ "register",
35
+ "replace",
36
+ "retrain",
37
+ "train",
38
+ ]
pecca/api.py ADDED
@@ -0,0 +1,406 @@
1
+ """Public API. Every function is re-exported from ``pecca``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ import pandas as pd
11
+
12
+ from pecca import audit as _audit
13
+ from pecca import connectors
14
+ from pecca import governance as _gov
15
+ from pecca.candidates import register as register # noqa: PLC0414
16
+ from pecca.core import context
17
+ from pecca.core.call import Call, check_transition
18
+ from pecca.core.errors import GovernanceError, PeccaError
19
+ from pecca.core.models import CallProfile, Decision, EvalReport, Prediction, TrainResult
20
+ from pecca.core.project import Project
21
+ from pecca.core.timeutil import iso
22
+ from pecca.core.workspace import Workspace
23
+ from pecca.data.dataset import Dataset, canonicalize
24
+ from pecca.data.labels import join_labels
25
+ from pecca.evaluation import evaluate_call
26
+ from pecca.profiler import ProfileReport, profile_dataset
27
+ from pecca.runtime.decorator import replace as replace # noqa: PLC0414
28
+ from pecca.runtime.predictor import Bundle, get_predictor
29
+ from pecca.templates_util import render
30
+ from pecca.trainer.tournament import run_tournament
31
+
32
+ WS = str | Workspace | None
33
+ PR = str | Project | None
34
+
35
+
36
+ def _call(call: str, workspace: WS, project: PR) -> Call:
37
+ return context.get_call(call, workspace, project)
38
+
39
+
40
+ # ---- data ---------------------------------------------------------------------------------
41
+ def connect(
42
+ source: str | dict[str, Any],
43
+ *,
44
+ table: str | None = None,
45
+ path: str | None = None,
46
+ columns: dict[str, Any],
47
+ input_template: str | None = None,
48
+ **kwargs: Any,
49
+ ) -> Dataset:
50
+ """Read a data source into the canonical schema (spec §5.1)."""
51
+ cfg: dict[str, Any] = dict(source) if isinstance(source, dict) else {"type": source}
52
+ cfg.setdefault("columns", columns)
53
+ if input_template:
54
+ cfg["input_template"] = input_template
55
+ if table:
56
+ cfg["table"] = table
57
+ if path:
58
+ cfg["path"] = path
59
+ cfg.update(kwargs)
60
+ ds = connectors.create("datasource", cfg)
61
+ df = ds.read()
62
+ return Dataset(df, cfg["columns"], cfg.get("input_template"), source=str(cfg["type"]))
63
+
64
+
65
+ def _log_dataset(call: Call, include_fallbacks: bool) -> pd.DataFrame:
66
+ logs = call.registry.read_logs(call.key, "3650d")
67
+ if logs.empty or "llm_output" not in logs:
68
+ return pd.DataFrame()
69
+ logs = logs[logs["llm_output"].notna()]
70
+ if not include_fallbacks and "served_by" in logs:
71
+ logs = logs[logs["served_by"] != "fallback"]
72
+ if logs.empty:
73
+ return pd.DataFrame()
74
+ raw = pd.DataFrame(
75
+ {
76
+ "input": logs["input"],
77
+ "llm_output": logs["llm_output"],
78
+ "ts": logs["ts"],
79
+ "name": call.name,
80
+ }
81
+ )
82
+ return canonicalize(
83
+ raw, {"input": "input", "llm_output": "llm_output", "call_name": "name", "timestamp": "ts"}
84
+ )
85
+
86
+
87
+ def load_dataset(
88
+ call: Call, *, include_fallbacks: bool = True, include_human_labels: bool = True
89
+ ) -> Dataset:
90
+ """Configured datasource rows (+ label join) plus recorded logs for this call."""
91
+ frames: list[pd.DataFrame] = []
92
+ template = None
93
+ mapping: dict[str, Any] = {}
94
+ cfg = call.project.config
95
+ ds = call.project.datasource()
96
+ if ds is not None:
97
+ df = ds.read(call=call.name)
98
+ template = getattr(ds, "input_template", None)
99
+ mapping = getattr(ds, "columns", {})
100
+ lab = cfg.get("labels")
101
+ if lab and include_human_labels:
102
+ lconn = connectors.create(
103
+ "datasource",
104
+ {
105
+ **lab,
106
+ "columns": {
107
+ "input": lab["join_on"],
108
+ "llm_output": lab["column"],
109
+ "call_name": {"literal": call.name},
110
+ },
111
+ },
112
+ )
113
+ ldf = lconn._read_raw(None, None) # noqa: SLF001
114
+ df = join_labels(df, ldf, lab["join_on"], lab["column"])
115
+ if not include_human_labels:
116
+ df["human_label"] = None
117
+ if not df.empty:
118
+ frames.append(df)
119
+ logged = _log_dataset(call, include_fallbacks)
120
+ if not logged.empty:
121
+ frames.append(logged)
122
+ if not frames:
123
+ raise PeccaError(
124
+ f"no data for {call.path}",
125
+ "check the call name against `pecca connect`, pass dataset=..., or record calls with @pecca.replace",
126
+ )
127
+ df = pd.concat(frames, ignore_index=True)
128
+ return Dataset(df, mapping, template, source="project")
129
+
130
+
131
+ def profile(
132
+ dataset: Dataset | None = None,
133
+ call: str | None = None,
134
+ *,
135
+ min_rows: int = 500,
136
+ workspace: WS = None,
137
+ project: PR = None,
138
+ ) -> ProfileReport:
139
+ """Profile each call in ``dataset`` (default: the project's configured datasource)."""
140
+ if dataset is None:
141
+ if call is None:
142
+ raise PeccaError(
143
+ "profile() needs a dataset or a call", "profile(dataset) or profile(call=...)"
144
+ )
145
+ dataset = load_dataset(_call(call, workspace, project))
146
+ call = _call(call, workspace, project).name
147
+ return profile_dataset(dataset, call, min_rows)
148
+
149
+
150
+ # ---- training -----------------------------------------------------------------------------
151
+ def train(
152
+ call: str,
153
+ dataset: Dataset | None = None,
154
+ *,
155
+ metric: str | None = None,
156
+ latency_budget_ms: float | None = None,
157
+ target_precision: float | None = None,
158
+ candidates: list[str] | None = None,
159
+ min_rows: int | None = None,
160
+ workspace: WS = None,
161
+ project: PR = None,
162
+ progress: Callable[[str], None] | None = None,
163
+ include_fallbacks: bool = True,
164
+ include_human_labels: bool = True,
165
+ ) -> TrainResult:
166
+ c = _call(call, workspace, project)
167
+ pol = c.project.policy(c.name)
168
+ if dataset is None:
169
+ dataset = load_dataset(
170
+ c, include_fallbacks=include_fallbacks, include_human_labels=include_human_labels
171
+ )
172
+ mr = min_rows if min_rows is not None else int(pol.get("min_rows", 500))
173
+ prof: CallProfile = profile_dataset(dataset, c.name, mr).profiles[c.name]
174
+ if not prof.replaceable:
175
+ raise PeccaError(f"{c.path} is not replaceable: {prof.reason}", "see `pecca profile`")
176
+ version = c.next_version()
177
+ with tempfile.TemporaryDirectory() as work:
178
+ out = run_tournament(
179
+ c.name,
180
+ dataset,
181
+ prof,
182
+ version=version,
183
+ workdir=work,
184
+ metric=metric or pol.get("metric"),
185
+ latency_budget_ms=latency_budget_ms
186
+ if latency_budget_ms is not None
187
+ else pol.get("latency_budget_ms"),
188
+ target_precision=target_precision
189
+ if target_precision is not None
190
+ else float(pol.get("target_precision", 0.95)),
191
+ candidates=candidates,
192
+ progress=progress,
193
+ )
194
+ mv = out.version
195
+ path = c.registry.save_model(c.key, version, out.artefacts_dir, mv.to_dict())
196
+ st = c.state()
197
+ st.policy = pol
198
+ st.last_trained = iso()
199
+ if st.mode == "live": # a new model must re-earn live status
200
+ st.mode, st.shadow_since = "shadow", iso()
201
+ c.log_event(st, "retrained_demoted_to_shadow", version=version)
202
+ if st.mode in ("off", "record") or st.current_version is None or st.mode in ("shadow", "live"):
203
+ st.current_version = version
204
+ if not c.has_state():
205
+ st.since = iso()
206
+ c.save_state(st)
207
+ c.notify(
208
+ "trained",
209
+ {"version": version, "message": f"{mv.candidate} {mv.metric_name} {mv.metric:.3f}"},
210
+ )
211
+ return TrainResult(
212
+ version,
213
+ mv.candidate,
214
+ mv.metric_name,
215
+ mv.metric,
216
+ mv.metric_std,
217
+ mv.llm_metric,
218
+ mv.threshold,
219
+ mv.expected_fallback_rate,
220
+ mv.latency_ms,
221
+ mv.leaderboard,
222
+ path,
223
+ mv.lineage,
224
+ )
225
+
226
+
227
+ def retrain(
228
+ call: str,
229
+ *,
230
+ include_fallbacks: bool = True,
231
+ include_human_labels: bool = True,
232
+ workspace: WS = None,
233
+ project: PR = None,
234
+ **kw: Any,
235
+ ) -> TrainResult:
236
+ return train(
237
+ call,
238
+ None,
239
+ include_fallbacks=include_fallbacks,
240
+ include_human_labels=include_human_labels,
241
+ workspace=workspace,
242
+ project=project,
243
+ **kw,
244
+ )
245
+
246
+
247
+ def predict(
248
+ call: str, inputs: list[Any], *, workspace: WS = None, project: PR = None
249
+ ) -> list[Prediction]:
250
+ return get_predictor(_call(call, workspace, project)).predict(list(inputs))
251
+
252
+
253
+ def evaluate(
254
+ call: str, since: str = "14d", *, workspace: WS = None, project: PR = None
255
+ ) -> EvalReport:
256
+ return evaluate_call(_call(call, workspace, project), since)
257
+
258
+
259
+ # ---- promotion & governance ---------------------------------------------------------------
260
+ def promote(
261
+ call: str, mode: str, *, force: bool = False, workspace: WS = None, project: PR = None
262
+ ) -> Decision:
263
+ c = _call(call, workspace, project)
264
+ st = c.state()
265
+ src = st.mode if c.has_state() else "record"
266
+ check_transition(src, mode)
267
+ transition = f"{src}->{mode}"
268
+ if src == mode:
269
+ return Decision(True, transition=transition)
270
+ if mode in ("shadow", "live") and not c.versions():
271
+ raise PeccaError(f"{c.path} has no trained model", f"run `pecca train {c.path}` first")
272
+ dec = Decision(True, transition=transition)
273
+ if transition in _gov.engine.GATED:
274
+ cfg = (c.project.governance.get("transitions") or {}).get(transition) or {}
275
+ if cfg.get("evidence"):
276
+ _audit.generate(c)
277
+ dec = _gov.evaluate(c, transition)
278
+ _ensure_ticket(c, transition, cfg, dec)
279
+ if not dec.allowed:
280
+ if not force:
281
+ return dec
282
+ if os.environ.get("PECCA_ALLOW_FORCE") != "1":
283
+ raise GovernanceError(
284
+ "--force requires PECCA_ALLOW_FORCE=1", "set it explicitly to override gates"
285
+ )
286
+ dec.forced = True
287
+ st = c.state()
288
+ c.log_event(
289
+ st,
290
+ "forced_promotion",
291
+ transition=transition,
292
+ failing_gates=dec.failing_gates,
293
+ pending_approvals=dec.pending_approvals,
294
+ evidence_missing=dec.evidence_missing,
295
+ )
296
+ c.save_state(st)
297
+ st = c.state()
298
+ st.mode, st.since = mode, iso()
299
+ if mode == "shadow" and src in ("record", "off"):
300
+ st.shadow_since = iso()
301
+ if st.current_version is None:
302
+ st.current_version = c.version().version
303
+ c.log_event(st, "mode_change", frm=src, to=mode, forced=dec.forced)
304
+ c.save_state(st)
305
+ c.notify(mode, {"version": st.current_version})
306
+ return dec
307
+
308
+
309
+ def _ensure_ticket(c: Call, transition: str, cfg: dict[str, Any], dec: Decision) -> None:
310
+ ap = cfg.get("approvals") or {}
311
+ via = str(ap.get("via", "manual"))
312
+ st = c.state()
313
+ mv = c.version()
314
+ if not dec.pending_approvals or via == "manual" or mv.version in st.tickets:
315
+ return
316
+ ticketer = c.project.ticketer()
317
+ if ticketer is None:
318
+ return
319
+ from pecca.audit.pack import build_context
320
+
321
+ ctx = build_context(c, mv.version)
322
+ ctx["transition"] = transition
323
+ ctx["policy"] = {**ctx["policy"], "approvals": ap}
324
+ body = render("approval_ticket.md.j2", **ctx)
325
+ tid = ticketer.create(
326
+ f"Pecca: approve {c.path} {mv.version} ({transition})",
327
+ body,
328
+ {"call": str(c.path), "version": mv.version},
329
+ )
330
+ st.tickets[mv.version] = tid
331
+ c.save_state(st)
332
+ c.notify("ready_for_approval", {"version": mv.version, "message": f"ticket {tid}"})
333
+
334
+
335
+ def approve(
336
+ call: str,
337
+ version: str,
338
+ *,
339
+ approver: str,
340
+ ticket: str | None = None,
341
+ note: str | None = None,
342
+ groups: list[str] | None = None,
343
+ workspace: WS = None,
344
+ project: PR = None,
345
+ ) -> dict[str, Any]:
346
+ c = _call(call, workspace, project)
347
+ c.version(version) # raises if unknown
348
+ if groups is None:
349
+ try:
350
+ ident = connectors.get("identity", "oidc")().current_principal()
351
+ groups = ident["groups"] if ident.get("user") == approver else []
352
+ except Exception: # noqa: BLE001
353
+ groups = []
354
+ rec = {
355
+ "version": version,
356
+ "approver": approver,
357
+ "groups": groups,
358
+ "ticket": ticket,
359
+ "note": note,
360
+ "ts": iso(),
361
+ }
362
+ st = c.state()
363
+ st.approvals.append(rec)
364
+ c.log_event(st, "approval", approver=approver, version=version)
365
+ c.save_state(st)
366
+ return rec
367
+
368
+
369
+ def audit_pack(
370
+ call: str,
371
+ version: str | None = None,
372
+ *,
373
+ out: str = "markdown",
374
+ path: str | None = None,
375
+ workspace: WS = None,
376
+ project: PR = None,
377
+ ) -> str:
378
+ return _audit.export(_call(call, workspace, project), version, out, path)
379
+
380
+
381
+ def load(path: str) -> Bundle:
382
+ """Load a saved model directory (e.g. downloaded from Hugging Face) for local prediction."""
383
+ return Bundle(path, "local")
384
+
385
+
386
+ # ---- agent tools --------------------------------------------------------------------------
387
+ def as_tool(
388
+ call: str, *, project: PR = None, workspace: WS = None, description: str | None = None
389
+ ) -> Callable[[str], dict[str, Any]]:
390
+ """A plain typed callable ``f(text) -> {label, confidence, fallback, version}`` for agent frameworks."""
391
+
392
+ def tool(text: str) -> dict[str, Any]:
393
+ p = predict(call, [text], workspace=workspace, project=project)[0]
394
+ return {
395
+ "label": p.label,
396
+ "confidence": p.confidence,
397
+ "fallback": p.fallback,
398
+ "version": p.version,
399
+ }
400
+
401
+ tool.__name__ = call.rsplit("/", 1)[-1]
402
+ tool.__doc__ = description or (
403
+ f"Classify text with the Pecca model for `{call}`. Returns the label, calibrated confidence, "
404
+ "and `fallback=True` when confidence is below the threshold (ask the LLM instead)."
405
+ )
406
+ return tool
@@ -0,0 +1,3 @@
1
+ from pecca.audit.pack import export, generate
2
+
3
+ __all__ = ["export", "generate"]
pecca/audit/pack.py ADDED
@@ -0,0 +1,110 @@
1
+ """Generate an audit pack for one model version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pecca import evaluation
10
+ from pecca.audit import renderer, signing
11
+ from pecca.audit.sections import EXTRA_SECTIONS, SECTIONS
12
+ from pecca.core.call import Call
13
+ from pecca.core.errors import PeccaError
14
+ from pecca.core.timeutil import iso
15
+ from pecca.governance.evidence import audit_dir
16
+ from pecca.templates_util import render
17
+
18
+
19
+ def build_context(call: Call, version: str) -> dict[str, Any]:
20
+ mv = call.version(version)
21
+ st = call.state()
22
+ gov = call.project.governance
23
+ trans = (gov.get("transitions") or {}).get("shadow->live") or {}
24
+ controls = [c for t in (gov.get("transitions") or {}).values() for c in t.get("controls", [])]
25
+ rep = evaluation.evaluate_call(call, "30d")
26
+ return {
27
+ "call": {"path": str(call.path), "name": call.name, "mode": st.mode, "version": version},
28
+ "model": mv.to_dict(),
29
+ "shadow": rep.to_dict(),
30
+ "policy": {**trans, "call": call.project.policy(call.name)},
31
+ "governance": gov,
32
+ "workspace": call.path.workspace,
33
+ "project": call.path.project,
34
+ "now": iso(),
35
+ "audit": {
36
+ "approvals": [a for a in st.approvals if a.get("version") == version],
37
+ "ticket": st.tickets.get(version),
38
+ "controls": controls,
39
+ "history": st.history,
40
+ "shadow_days": evaluation.shadow_days(call),
41
+ "retention": gov.get("retention"),
42
+ "hashes": [],
43
+ "signed": False,
44
+ },
45
+ }
46
+
47
+
48
+ def generate(call: Call, version: str | None = None) -> Path:
49
+ """Write all sections + manifest to ``audit/<version>/`` and return that directory."""
50
+ version = version or call.version().version
51
+ ctx = build_context(call, version)
52
+ out = audit_dir(call, version)
53
+ for f in out.glob("*"):
54
+ if f.is_file():
55
+ f.unlink()
56
+ evidence = {
57
+ e
58
+ for t in (ctx["governance"].get("transitions") or {}).values()
59
+ for e in t.get("evidence", [])
60
+ }
61
+ names = [*SECTIONS, *[s for s in EXTRA_SECTIONS if s in evidence]]
62
+ for name in names:
63
+ builder = SECTIONS.get(name)
64
+ extras = builder(ctx, out) if builder else {}
65
+ (out / f"{name}.md").write_text(renderer.render_section(name, ctx, extras))
66
+ (out / "audit.md").write_text(combined_markdown(out)) # included in the manifest below
67
+ ret = ctx["governance"].get("retention") or {}
68
+ pre = signing.hashes(out)
69
+ ctx["audit"]["hashes"] = pre
70
+ ctx["audit"]["signed"] = bool(ret.get("signed"))
71
+ (out / "manifest.md").write_text(renderer.render_section("manifest", ctx, {}))
72
+ signing.write_manifest(out, ret.get("gpg_key") if ret.get("signed") else None)
73
+ return out
74
+
75
+
76
+ def combined_markdown(directory: Path) -> str:
77
+ order = [*SECTIONS, "human_oversight_log", "manifest"]
78
+ parts = [(directory / f"{n}.md").read_text() for n in order if (directory / f"{n}.md").exists()]
79
+ return "\n\n---\n\n".join(parts)
80
+
81
+
82
+ def export(call: Call, version: str | None, out: str, path: str | None) -> str:
83
+ """Return the produced path (markdown/pdf), page URL (confluence) or JSON text."""
84
+ version = version or call.version().version
85
+ d = generate(call, version)
86
+ md = combined_markdown(d)
87
+ if out == "markdown":
88
+ dest = Path(path) if path else d
89
+ dest.mkdir(parents=True, exist_ok=True)
90
+ if dest != d:
91
+ for f in d.iterdir():
92
+ (dest / f.name).write_bytes(f.read_bytes())
93
+ return str(dest / "audit.md")
94
+ if out == "json":
95
+ ctx = build_context(call, version)
96
+ ctx["audit"].pop("hashes", None)
97
+ return json.dumps(ctx, indent=2, default=str)
98
+ if out == "pdf":
99
+ dest = Path(path) if path else d
100
+ dest.mkdir(parents=True, exist_ok=True)
101
+ return renderer.to_pdf(md, str(dest / "audit.pdf"))
102
+ if out == "confluence":
103
+ pub = call.project.docs_publisher()
104
+ if pub is None:
105
+ raise PeccaError("no docs publisher configured", "add integrations.docs to pecca.yaml")
106
+ ctx = build_context(call, version)
107
+ title = f"Pecca audit — {call.path} {version}"
108
+ html = render("confluence_page.html.j2", **ctx, markdown=md)
109
+ return pub.publish(title, html, {"call": str(call.path), "version": version})
110
+ raise PeccaError(f"unknown audit output {out!r}", "use markdown, pdf, confluence or json")
@@ -0,0 +1,29 @@
1
+ """Render sections to Markdown and convert to other formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pecca.core.errors import PeccaError
8
+ from pecca.templates_util import render
9
+
10
+
11
+ def render_section(name: str, ctx: dict[str, Any], extras: dict[str, Any]) -> str:
12
+ audit = {**ctx["audit"], **extras}
13
+ return render(f"audit_pack/{name}.md.j2", **{**ctx, "audit": audit})
14
+
15
+
16
+ def to_pdf(markdown_text: str, path: str) -> str:
17
+ try:
18
+ import markdown as md
19
+ from weasyprint import HTML
20
+ except ImportError as e:
21
+ raise PeccaError("PDF output needs extra dependencies", "pip install 'pecca[pdf]'") from e
22
+ except OSError as e: # weasyprint is installed but pango/glib are not
23
+ raise PeccaError(
24
+ f"PDF output needs system libraries ({e})",
25
+ "install pango (macOS: brew install pango; Debian/Ubuntu: apt install libpango-1.0-0)",
26
+ ) from e
27
+ html = md.markdown(markdown_text, extensions=["tables", "fenced_code"])
28
+ HTML(string=f"<html><body>{html}</body></html>").write_pdf(path)
29
+ return path
@@ -0,0 +1,28 @@
1
+ """Audit pack sections: name -> extras builder (``None`` = template needs no extras)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pecca.audit.sections import operations, performance
10
+
11
+ Builder = Callable[[dict[str, Any], Path], dict[str, Any]]
12
+
13
+ SECTIONS: dict[str, Builder | None] = {
14
+ "summary": None,
15
+ "model_card": None,
16
+ "data_lineage": None,
17
+ "benchmark_leaderboard": None,
18
+ "confusion_matrix": performance.confusion_matrix,
19
+ "per_class_report": performance.per_class,
20
+ "calibration": performance.calibration,
21
+ "threshold_rationale": None,
22
+ "shadow_results": operations.shadow_results,
23
+ "approvals": operations.approvals,
24
+ "drift_report": None,
25
+ "controls_mapping": operations.controls_mapping,
26
+ "environment": operations.environment,
27
+ }
28
+ EXTRA_SECTIONS = ("human_oversight_log",) # rendered only when governance evidence asks for it
@@ -0,0 +1,24 @@
1
+ """Section extras that depend on runtime state (shadow results, approvals, controls)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pecca.governance.retention import describe
9
+
10
+
11
+ def shadow_results(ctx: dict[str, Any], out: Path) -> dict[str, Any]:
12
+ return {}
13
+
14
+
15
+ def approvals(ctx: dict[str, Any], out: Path) -> dict[str, Any]:
16
+ return {}
17
+
18
+
19
+ def controls_mapping(ctx: dict[str, Any], out: Path) -> dict[str, Any]:
20
+ return {}
21
+
22
+
23
+ def environment(ctx: dict[str, Any], out: Path) -> dict[str, Any]:
24
+ return {"retention": describe(ctx["governance"].get("retention"))}