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.
- pecca/__init__.py +38 -0
- pecca/api.py +406 -0
- pecca/audit/__init__.py +3 -0
- pecca/audit/pack.py +110 -0
- pecca/audit/renderer.py +29 -0
- pecca/audit/sections/__init__.py +28 -0
- pecca/audit/sections/operations.py +24 -0
- pecca/audit/sections/performance.py +66 -0
- pecca/audit/signing.py +48 -0
- pecca/candidates/__init__.py +19 -0
- pecca/candidates/base.py +184 -0
- pecca/candidates/classical.py +49 -0
- pecca/candidates/embeddings.py +162 -0
- pecca/candidates/registry.py +127 -0
- pecca/candidates/tabular.py +74 -0
- pecca/candidates/transformers_ft.py +156 -0
- pecca/cli/__init__.py +0 -0
- pecca/cli/commands/__init__.py +0 -0
- pecca/cli/commands/ops.py +92 -0
- pecca/cli/commands/pipeline.py +384 -0
- pecca/cli/commands/setup.py +202 -0
- pecca/cli/common.py +39 -0
- pecca/cli/main.py +47 -0
- pecca/config/__init__.py +0 -0
- pecca/config/apply.py +42 -0
- pecca/config/loader.py +87 -0
- pecca/config/resolve.py +82 -0
- pecca/config/schema.json +62 -0
- pecca/connectors/__init__.py +5 -0
- pecca/connectors/_mcp.py +76 -0
- pecca/connectors/base.py +207 -0
- pecca/connectors/datasource/__init__.py +0 -0
- pecca/connectors/datasource/_base.py +47 -0
- pecca/connectors/datasource/_chat.py +56 -0
- pecca/connectors/datasource/_sql.py +49 -0
- pecca/connectors/datasource/bigquery.py +28 -0
- pecca/connectors/datasource/csv.py +24 -0
- pecca/connectors/datasource/databricks.py +44 -0
- pecca/connectors/datasource/databricks_ai_gateway.py +50 -0
- pecca/connectors/datasource/litellm_logs.py +57 -0
- pecca/connectors/datasource/mcp.py +35 -0
- pecca/connectors/datasource/otel_traces.py +117 -0
- pecca/connectors/datasource/parquet.py +24 -0
- pecca/connectors/datasource/postgres.py +45 -0
- pecca/connectors/datasource/snowflake.py +58 -0
- pecca/connectors/docs_publisher/__init__.py +0 -0
- pecca/connectors/docs_publisher/confluence.py +65 -0
- pecca/connectors/docs_publisher/markdown.py +19 -0
- pecca/connectors/identity/__init__.py +0 -0
- pecca/connectors/identity/oidc.py +39 -0
- pecca/connectors/notifier/__init__.py +0 -0
- pecca/connectors/notifier/mcp.py +23 -0
- pecca/connectors/notifier/slack.py +24 -0
- pecca/connectors/notifier/teams.py +36 -0
- pecca/connectors/notifier/webhook.py +31 -0
- pecca/connectors/registry/__init__.py +0 -0
- pecca/connectors/registry/_pyfunc.py +28 -0
- pecca/connectors/registry/local.py +110 -0
- pecca/connectors/registry/mlflow.py +260 -0
- pecca/connectors/registry/wandb.py +159 -0
- pecca/connectors/scheduler/__init__.py +0 -0
- pecca/connectors/scheduler/airflow.py +54 -0
- pecca/connectors/scheduler/cron.py +46 -0
- pecca/connectors/scheduler/databricks_workflows.py +67 -0
- pecca/connectors/secrets/__init__.py +0 -0
- pecca/connectors/secrets/aws_secrets_manager.py +35 -0
- pecca/connectors/secrets/env.py +20 -0
- pecca/connectors/secrets/vault.py +43 -0
- pecca/connectors/serving/__init__.py +0 -0
- pecca/connectors/serving/databricks_serving.py +49 -0
- pecca/connectors/serving/docker_export.py +74 -0
- pecca/connectors/serving/onnx_local.py +21 -0
- pecca/connectors/ticketer/__init__.py +0 -0
- pecca/connectors/ticketer/github_issues.py +62 -0
- pecca/connectors/ticketer/jira.py +117 -0
- pecca/connectors/ticketer/manual.py +61 -0
- pecca/connectors/ticketer/mcp.py +54 -0
- pecca/core/__init__.py +0 -0
- pecca/core/call.py +108 -0
- pecca/core/context.py +62 -0
- pecca/core/errors.py +34 -0
- pecca/core/models.py +126 -0
- pecca/core/paths.py +36 -0
- pecca/core/project.py +44 -0
- pecca/core/timeutil.py +45 -0
- pecca/core/workspace.py +78 -0
- pecca/data/__init__.py +0 -0
- pecca/data/dataset.py +205 -0
- pecca/data/labels.py +29 -0
- pecca/data/synthetic.py +630 -0
- pecca/evaluation.py +141 -0
- pecca/governance/__init__.py +3 -0
- pecca/governance/approvals.py +57 -0
- pecca/governance/engine.py +37 -0
- pecca/governance/evidence.py +27 -0
- pecca/governance/gates.py +79 -0
- pecca/governance/retention.py +18 -0
- pecca/governance/schedule.py +38 -0
- pecca/governance/schema.json +54 -0
- pecca/integrations/__init__.py +1 -0
- pecca/integrations/_common.py +23 -0
- pecca/integrations/claude_agent.py +26 -0
- pecca/integrations/google_adk.py +15 -0
- pecca/integrations/langgraph.py +15 -0
- pecca/integrations/openai_agents.py +15 -0
- pecca/integrations/strands.py +15 -0
- pecca/mcp/__init__.py +0 -0
- pecca/mcp/server.py +116 -0
- pecca/profiler/__init__.py +3 -0
- pecca/profiler/profiler.py +78 -0
- pecca/profiler/rules.py +89 -0
- pecca/profiles/README.md +11 -0
- pecca/profiles/bsp.yaml +17 -0
- pecca/profiles/cbuae.yaml +17 -0
- pecca/profiles/default.yaml +14 -0
- pecca/profiles/eu-ai-act.yaml +17 -0
- pecca/profiles/none.yaml +10 -0
- pecca/profiles/rbi.yaml +17 -0
- pecca/profiles/sarb.yaml +17 -0
- pecca/profiles/sr11-7.yaml +17 -0
- pecca/runtime/__init__.py +3 -0
- pecca/runtime/decorator.py +217 -0
- pecca/runtime/fallback.py +21 -0
- pecca/runtime/logging_sink.py +84 -0
- pecca/runtime/predictor.py +119 -0
- pecca/runtime/telemetry.py +41 -0
- pecca/templates/__init__.py +0 -0
- pecca/templates/approval_ticket.md.j2 +14 -0
- pecca/templates/audit_pack/__init__.py +0 -0
- pecca/templates/audit_pack/approvals.md.j2 +11 -0
- pecca/templates/audit_pack/benchmark_leaderboard.md.j2 +7 -0
- pecca/templates/audit_pack/calibration.md.j2 +11 -0
- pecca/templates/audit_pack/confusion_matrix.md.j2 +8 -0
- pecca/templates/audit_pack/controls_mapping.md.j2 +8 -0
- pecca/templates/audit_pack/data_lineage.md.j2 +11 -0
- pecca/templates/audit_pack/drift_report.md.j2 +4 -0
- pecca/templates/audit_pack/environment.md.j2 +8 -0
- pecca/templates/audit_pack/human_oversight_log.md.j2 +9 -0
- pecca/templates/audit_pack/manifest.md.j2 +6 -0
- pecca/templates/audit_pack/model_card.md.j2 +1 -0
- pecca/templates/audit_pack/per_class_report.md.j2 +7 -0
- pecca/templates/audit_pack/shadow_results.md.j2 +9 -0
- pecca/templates/audit_pack/summary.md.j2 +12 -0
- pecca/templates/audit_pack/threshold_rationale.md.j2 +13 -0
- pecca/templates/confluence_page.html.j2 +3 -0
- pecca/templates/model_card.md.j2 +12 -0
- pecca/templates/slack_event.md.j2 +2 -0
- pecca/templates_util.py +41 -0
- pecca/trainer/__init__.py +3 -0
- pecca/trainer/calibration.py +84 -0
- pecca/trainer/export_onnx.py +42 -0
- pecca/trainer/lineage.py +72 -0
- pecca/trainer/metrics.py +73 -0
- pecca/trainer/splits.py +42 -0
- pecca/trainer/threshold.py +37 -0
- pecca/trainer/tournament.py +259 -0
- pecca-0.1.0.dist-info/METADATA +181 -0
- pecca-0.1.0.dist-info/RECORD +162 -0
- pecca-0.1.0.dist-info/WHEEL +4 -0
- pecca-0.1.0.dist-info/entry_points.txt +2 -0
- pecca-0.1.0.dist-info/licenses/LICENSE +191 -0
- 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
|
pecca/audit/__init__.py
ADDED
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")
|
pecca/audit/renderer.py
ADDED
|
@@ -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"))}
|