cfgit-impact 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.
cfg_impact/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """Optional cfgit system-impact plugin."""
3
+
4
+ from cfg_impact.overview import deterministic_overview, overview, overview_with_optional_llm
5
+
6
+ __all__ = ["deterministic_overview", "overview", "overview_with_optional_llm"]
cfg_impact/overview.py ADDED
@@ -0,0 +1,432 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """Whole-system impact overview for cfgit diffs."""
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from typing import Any
9
+
10
+ from cfg.core.engine import Engine, RecordRef
11
+ from cfg.core.hashing import strip_for_hash
12
+ from cfg.interfaces.actions import parse_record, to_json
13
+ from cfg_impact.providers.factory import ImpactProviderFactory
14
+
15
+
16
+ RISKY_TOKENS = {
17
+ "enabled", "active", "jobrouter", "provider", "model", "fallback",
18
+ "retry", "timeout", "pricing", "price", "cost", "tool", "tools",
19
+ "skill", "skills", "contract", "schema", "instructions", "prompt",
20
+ }
21
+
22
+ HIGH_TOKENS = {
23
+ "enabled", "active", "jobrouter", "provider", "model", "fallback",
24
+ "contract", "schema", "tool", "tools",
25
+ }
26
+
27
+ _CONSENT_LOGGED: set[tuple[str, tuple[str, ...]]] = set()
28
+
29
+
30
+ def deterministic_overview(
31
+ engine: Engine,
32
+ record: str,
33
+ *,
34
+ a: str = "=HEAD",
35
+ b: str = "=live",
36
+ ) -> dict[str, Any]:
37
+ ref = parse_record(record)
38
+ coll = engine.config.collection(ref.collection)
39
+ changes = engine.diff(ref, a, b)
40
+ left = strip_for_hash(engine.resolve_ref(ref, a)["doc"], coll)
41
+ right = strip_for_hash(engine.resolve_ref(ref, b)["doc"], coll)
42
+ paths = [str(change.get("path", "")) for change in changes]
43
+ changed_values = _changed_string_values(changes)
44
+ affected = _find_affected_records(engine, source=ref, values=[ref.record_id, *changed_values])
45
+ declared_links = _declared_links(engine, paths)
46
+ categories = _categories(paths)
47
+ risk_level = _risk_level(paths, changes, affected)
48
+ summary = _deterministic_summary(record, changes, categories, affected, declared_links, risk_level)
49
+
50
+ return {
51
+ "record": record,
52
+ "from": a,
53
+ "to": b,
54
+ "risk_level": risk_level,
55
+ "summary": summary,
56
+ "categories": categories,
57
+ "changed_paths": paths,
58
+ "change_count": len(changes),
59
+ "declared_links_changed": declared_links,
60
+ "affected_records": affected,
61
+ "rollback_note": "Use restore on this record or restore system by tag/as-of if this change already shipped.",
62
+ "unknowns": _unknowns(left, right, affected),
63
+ "changes": changes,
64
+ }
65
+
66
+
67
+ async def overview_with_optional_llm(
68
+ engine: Engine,
69
+ record: str,
70
+ *,
71
+ a: str = "=HEAD",
72
+ b: str = "=live",
73
+ provider: str | None = None,
74
+ model: str | None = None,
75
+ use_llm: bool = False,
76
+ against: list[str] | None = None,
77
+ ) -> dict[str, Any]:
78
+ overview_data = deterministic_overview(engine, record, a=a, b=b)
79
+ if not use_llm:
80
+ overview_data["llm"] = {"enabled": False}
81
+ return overview_data
82
+
83
+ allowed, reason = _llm_allowed(engine, record)
84
+ provider_name = provider or engine.config.connections.ai_provider
85
+ if not allowed:
86
+ overview_data["llm"] = {
87
+ "enabled": False,
88
+ "blocked": True,
89
+ "provider": provider_name,
90
+ "reason": reason,
91
+ }
92
+ return overview_data
93
+
94
+ llm = ImpactProviderFactory.create_provider(provider_name, model=model)
95
+ # cross-config context. If the caller selected records (`against`), reason against
96
+ # ONLY those; otherwise auto-build from the whole system. Text always gated by allowlist.
97
+ ref = parse_record(record)
98
+ allow = set(engine.config.connections.share_with_ai)
99
+ against_set = {a.strip() for a in against if a and a.strip()} if against else None
100
+ system_map = _system_map(engine, exclude=ref, allow=allow, against=against_set)
101
+ shared = [c["record_id"] for c in system_map.get("configs", []) if "instructions_excerpt" in c or "contract" in c]
102
+ _log_llm_consent(llm.provider_name, [record, *shared])
103
+ payload = _overview_prompt_payload(overview_data)
104
+ payload["system"] = system_map
105
+ result = await llm.narrate(
106
+ payload,
107
+ json_dumps=lambda payload: json.dumps(payload, indent=2, sort_keys=True),
108
+ temperature=0.1,
109
+ max_tokens=1100,
110
+ )
111
+ parsed = _parse_jsonish(result.get("content", ""))
112
+ overview_data["llm"] = {
113
+ "enabled": True,
114
+ "provider": llm.provider_name,
115
+ "model": result.get("model") or llm.model,
116
+ "usage": result.get("usage") or {},
117
+ "overview": parsed or {"summary": result.get("content", "")},
118
+ }
119
+ return overview_data
120
+
121
+
122
+ def overview(
123
+ engine: Engine,
124
+ record: str,
125
+ *,
126
+ a: str = "=HEAD",
127
+ b: str = "=live",
128
+ provider: str | None = None,
129
+ model: str | None = None,
130
+ use_llm: bool = False,
131
+ against: list[str] | None = None,
132
+ ) -> dict[str, Any]:
133
+ return asyncio.run(
134
+ overview_with_optional_llm(
135
+ engine,
136
+ record,
137
+ a=a,
138
+ b=b,
139
+ provider=provider,
140
+ model=model,
141
+ use_llm=use_llm,
142
+ against=against,
143
+ )
144
+ )
145
+
146
+
147
+ _ROLE_FIELDS = ("agent_type", "role", "phase", "category", "description", "display_name")
148
+ _CONTRACT_FIELDS = ("phase_contract", "contract", "output_schema", "custom_input_schema")
149
+ _SYS_TEXT_CAP = 600 # per-field text excerpt sent in the system map
150
+ _SYS_MAX_CONFIGS = 40 # cap how many sibling configs we send
151
+
152
+
153
+ def _excerpt(value: Any, cap: int) -> str | None:
154
+ if not isinstance(value, str) or not value.strip():
155
+ return None
156
+ s = " ".join(value.split())
157
+ return s[:cap] + ("…" if len(s) > cap else "")
158
+
159
+
160
+ def _config_card(doc: dict[str, Any], *, with_text: bool) -> dict[str, Any]:
161
+ card: dict[str, Any] = {}
162
+ for f in _ROLE_FIELDS:
163
+ if doc.get(f):
164
+ card[f] = doc[f]
165
+ break
166
+ tools = doc.get("tools")
167
+ if isinstance(tools, list) and tools:
168
+ card["tools"] = tools[:12]
169
+ skills = doc.get("skills")
170
+ if isinstance(skills, list) and skills:
171
+ card["skills"] = skills[:12]
172
+ if with_text:
173
+ for f in _CONTRACT_FIELDS:
174
+ ex = _excerpt(doc.get(f), _SYS_TEXT_CAP)
175
+ if ex:
176
+ card["contract"] = ex
177
+ break
178
+ if doc.get("model"):
179
+ card["model"] = doc["model"]
180
+ if with_text:
181
+ instr = doc.get("instructions") or doc.get("prompt")
182
+ ex = _excerpt(instr, _SYS_TEXT_CAP)
183
+ if ex:
184
+ card["instructions_excerpt"] = ex
185
+ return card
186
+
187
+
188
+ def _system_map(
189
+ engine: Engine,
190
+ *,
191
+ exclude: RecordRef,
192
+ allow: set[str],
193
+ against: set[str] | None = None,
194
+ ) -> dict[str, Any]:
195
+ """Compact view of the OTHER live configs so the model reasons cross-system.
196
+ A config's text (instructions/contract) is included only if that config is in
197
+ the share_with_ai allowlist; otherwise just id + collection are sent.
198
+
199
+ If `against` is given, ONLY records the user explicitly selected are included
200
+ (matched by 'collection:record_id' or bare 'record_id'), and the non-rich tail
201
+ is suppressed: the user scoped the context, so we send exactly that set."""
202
+
203
+ def _allowed(coll: str, rid: str) -> bool:
204
+ return "*" in allow or rid in allow or f"{coll}:{rid}" in allow or f"{coll}:*" in allow
205
+
206
+ def _selected(coll: str, rid: str) -> bool:
207
+ return against is None or f"{coll}:{rid}" in against or rid in against
208
+
209
+ scoped = against is not None
210
+ out: list[dict[str, Any]] = []
211
+ for row in engine.status():
212
+ if row.collection == exclude.collection and row.record_id == exclude.record_id:
213
+ continue
214
+ if not _selected(row.collection, row.record_id):
215
+ continue
216
+ entry: dict[str, Any] = {"collection": row.collection, "record_id": row.record_id, "state": row.state}
217
+ with_text = _allowed(row.collection, row.record_id)
218
+ card: dict[str, Any] = {}
219
+ try:
220
+ doc = engine.resolve_ref(RecordRef(row.collection, row.record_id), "=live")["doc"]
221
+ coll = engine.config.collection(row.collection)
222
+ doc = strip_for_hash(doc, coll) # drop ignored/secret fields before egress
223
+ card = _config_card(doc, with_text=with_text)
224
+ except Exception:
225
+ pass
226
+ # When the user explicitly selected this record, always include it (that's the
227
+ # whole point of selecting). Otherwise (auto mode) only include rich cards that
228
+ # carry reasoning-relevant content, to avoid noise and needless egress.
229
+ rich = any(k in card for k in ("instructions_excerpt", "contract", "tools", "skills"))
230
+ if scoped or rich:
231
+ entry.update(card)
232
+ if not with_text:
233
+ entry["text_withheld"] = "not in share_with_ai"
234
+ out.append(entry)
235
+ if not scoped and len(out) >= _SYS_MAX_CONFIGS:
236
+ break
237
+ result: dict[str, Any] = {"configs": out, "scoped": scoped}
238
+ if not scoped:
239
+ # auto mode: append a compact tail of the remaining record ids so the model
240
+ # knows what else exists, without sending their bodies
241
+ result["other_record_ids"] = [
242
+ f"{r.collection}:{r.record_id}"
243
+ for r in engine.status()
244
+ if not (r.collection == exclude.collection and r.record_id == exclude.record_id)
245
+ ][:200]
246
+ return result
247
+
248
+
249
+ def _find_affected_records(
250
+ engine: Engine,
251
+ *,
252
+ source: RecordRef,
253
+ values: list[str],
254
+ ) -> list[dict[str, Any]]:
255
+ needles = [value for value in values if isinstance(value, str) and len(value) >= 3]
256
+ affected: list[dict[str, Any]] = []
257
+ for row in engine.status():
258
+ if row.collection == source.collection and row.record_id == source.record_id:
259
+ continue
260
+ try:
261
+ doc = engine.resolve_ref(RecordRef(row.collection, row.record_id), "=live")["doc"]
262
+ except Exception:
263
+ continue
264
+ text = json.dumps(to_json(doc), sort_keys=True)
265
+ matches = sorted({needle for needle in needles if needle in text})
266
+ if matches:
267
+ affected.append(
268
+ {
269
+ "collection": row.collection,
270
+ "record_id": row.record_id,
271
+ "state": row.state,
272
+ "matched_values": matches[:8],
273
+ }
274
+ )
275
+ return affected
276
+
277
+
278
+ def _changed_string_values(changes: list[dict[str, Any]]) -> list[str]:
279
+ values: list[str] = []
280
+ for change in changes:
281
+ for key in ("before", "after", "old", "new"):
282
+ value = change.get(key)
283
+ if isinstance(value, str) and 3 <= len(value) <= 120:
284
+ values.append(value)
285
+ elif isinstance(value, list):
286
+ values.extend(
287
+ str(item)
288
+ for item in value
289
+ if isinstance(item, str) and 3 <= len(item) <= 120
290
+ )
291
+ return values
292
+
293
+
294
+ def _categories(paths: list[str]) -> list[str]:
295
+ out: set[str] = set()
296
+ for path in paths:
297
+ lowered = path.lower()
298
+ if any(token in lowered for token in ("instructions", "prompt", "system")):
299
+ out.add("agent_behavior")
300
+ if any(token in lowered for token in ("tool", "skill", "contract", "schema")):
301
+ out.add("interface_or_contract")
302
+ if any(token in lowered for token in ("provider", "model", "fallback")):
303
+ out.add("model_routing")
304
+ if any(token in lowered for token in ("enabled", "active", "jobrouter", "retry", "timeout")):
305
+ out.add("runtime_routing")
306
+ if any(token in lowered for token in ("pricing", "price", "cost")):
307
+ out.add("cost")
308
+ return sorted(out) or ["data_change"]
309
+
310
+
311
+ def _declared_links(engine: Engine, paths: list[str]) -> list[dict[str, Any]]:
312
+ links = getattr(engine.config.connections, "links", ())
313
+ changed: list[dict[str, Any]] = []
314
+ for link in links:
315
+ field = str(link.get("field", ""))
316
+ if not field:
317
+ continue
318
+ field_path = f"/{field}".lower()
319
+ if any(path.lower() == field_path or path.lower().startswith(field_path + "/") for path in paths):
320
+ changed.append({"field": field, "means": link.get("means", "")})
321
+ return changed
322
+
323
+
324
+ def _risk_level(paths: list[str], changes: list[dict[str, Any]], affected: list[dict[str, Any]]) -> str:
325
+ lowered_paths = " ".join(paths).lower()
326
+ if any(token in lowered_paths for token in HIGH_TOKENS) or len(affected) >= 3:
327
+ return "high"
328
+ if any(token in lowered_paths for token in RISKY_TOKENS) or len(changes) >= 5 or affected:
329
+ return "medium"
330
+ return "low"
331
+
332
+
333
+ def _deterministic_summary(
334
+ record: str,
335
+ changes: list[dict[str, Any]],
336
+ categories: list[str],
337
+ affected: list[dict[str, Any]],
338
+ declared_links: list[dict[str, Any]],
339
+ risk_level: str,
340
+ ) -> str:
341
+ category_text = ", ".join(categories)
342
+ affected_text = f"{len(affected)} related record(s)" if affected else "no related records found by static scan"
343
+ link_text = (
344
+ f" Declared connection fields changed: {', '.join(item['field'] for item in declared_links)}."
345
+ if declared_links
346
+ else ""
347
+ )
348
+ return (
349
+ f"{record} changes {len(changes)} path(s), mainly {category_text}. "
350
+ f"Static scan found {affected_text}.{link_text} Risk is {risk_level}."
351
+ )
352
+
353
+
354
+ def _unknowns(left: dict[str, Any], right: dict[str, Any], affected: list[dict[str, Any]]) -> list[str]:
355
+ unknowns = []
356
+ if not affected:
357
+ unknowns.append("Static reference scan may miss dynamic references built in application code.")
358
+ if left == right:
359
+ unknowns.append("No effective diff after ignored and secret fields were stripped.")
360
+ return unknowns
361
+
362
+
363
+ _DIFF_FIELD_CAP = 6000 # per-side char cap so the prompt stays bounded
364
+
365
+
366
+ def _truncate(value: Any, cap: int = _DIFF_FIELD_CAP) -> Any:
367
+ if isinstance(value, str) and len(value) > cap:
368
+ return value[:cap] + f"\n…[truncated {len(value) - cap} chars]"
369
+ return value
370
+
371
+
372
+ def _overview_prompt_payload(overview_data: dict[str, Any]) -> dict[str, Any]:
373
+ payload = {key: value for key, value in overview_data.items() if key != "changes"}
374
+ # Include the ACTUAL field-level before/after so the model can read what changed,
375
+ # not just which fields changed. The diff is already secret-stripped upstream
376
+ # (engine.diff -> strip_for_hash), so no secret/ignored content reaches here.
377
+ payload["field_diffs"] = [
378
+ {
379
+ "path": change.get("path"),
380
+ "op": change.get("op"),
381
+ "before": _truncate(change.get("before")),
382
+ "after": _truncate(change.get("after")),
383
+ }
384
+ for change in overview_data.get("changes", [])
385
+ ]
386
+ payload["affected_records"] = [
387
+ {
388
+ "collection": item.get("collection"),
389
+ "record_id": item.get("record_id"),
390
+ "state": item.get("state"),
391
+ "match_count": len(item.get("matched_values") or []),
392
+ }
393
+ for item in payload.get("affected_records", [])
394
+ ]
395
+ return payload
396
+
397
+
398
+ def _llm_allowed(engine: Engine, record: str) -> tuple[bool, str]:
399
+ allow = set(engine.config.connections.share_with_ai)
400
+ ref = parse_record(record)
401
+ if "*" in allow or record in allow or ref.record_id in allow or f"{ref.collection}:*" in allow:
402
+ return True, "allowed by connections.share_with_ai"
403
+ return (
404
+ False,
405
+ f"{record} is not listed in [connections].share_with_ai; returning local structure only",
406
+ )
407
+
408
+
409
+ def _log_llm_consent(provider_name: str, records: list[str]) -> None:
410
+ key = (provider_name, tuple(sorted(records)))
411
+ if key in _CONSENT_LOGGED:
412
+ return
413
+ _CONSENT_LOGGED.add(key)
414
+ print(
415
+ f"cfg-impact: sending redacted structural diff for {', '.join(records)} to {provider_name}",
416
+ file=sys.stderr,
417
+ )
418
+
419
+
420
+ def _parse_jsonish(text: str) -> dict[str, Any] | None:
421
+ raw = text.strip()
422
+ if not raw:
423
+ return None
424
+ if raw.startswith("```"):
425
+ raw = raw.strip("`")
426
+ if raw.startswith("json"):
427
+ raw = raw[4:]
428
+ try:
429
+ data = json.loads(raw)
430
+ except json.JSONDecodeError:
431
+ return None
432
+ return data if isinstance(data, dict) else None
@@ -0,0 +1,7 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """Provider-agnostic LLM clients for cfg-impact."""
3
+
4
+ from cfg_impact.providers.base import BaseImpactProvider, ProviderError
5
+ from cfg_impact.providers.factory import ImpactProviderFactory
6
+
7
+ __all__ = ["BaseImpactProvider", "ImpactProviderFactory", "ProviderError"]
@@ -0,0 +1,79 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """Lean provider interface for cfg-impact narration."""
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ import json
7
+ from typing import Any
8
+
9
+
10
+ class BaseImpactProvider(ABC):
11
+ def __init__(self, api_key: str, model: str | None = None, **_: Any):
12
+ if not api_key:
13
+ raise ValueError(f"{self.__class__.__name__} requires an API key")
14
+ self.api_key = api_key
15
+ self.model = model or self.default_model
16
+
17
+ @property
18
+ @abstractmethod
19
+ def provider_name(self) -> str:
20
+ ...
21
+
22
+ @property
23
+ @abstractmethod
24
+ def default_model(self) -> str:
25
+ ...
26
+
27
+ @abstractmethod
28
+ async def complete(self, messages: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
29
+ ...
30
+
31
+ async def narrate(self, payload: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
32
+ json_dumps = kwargs.get(
33
+ "json_dumps",
34
+ lambda value: json.dumps(value, indent=2, sort_keys=True),
35
+ )
36
+ messages = [
37
+ {
38
+ "role": "system",
39
+ "content": (
40
+ "You are a senior engineer reviewing a change to a live agent/config control "
41
+ "plane. The JSON gives you `field_diffs` (the ACTUAL before/after of each changed "
42
+ "field) and `system` (a compact map of the OTHER live configs: their ids, roles, "
43
+ "instruction excerpts, tools, and contracts). If `system.scoped` is true, reason "
44
+ "about the change only against the selected records in `system.configs`; otherwise "
45
+ "reason about it against the whole system.\n"
46
+ "Be concrete. Say what the edit actually changes in behavior, quoting or paraphrasing "
47
+ "the specific rule, threshold, tool, or wording that changed. Then identify which OTHER "
48
+ "configs in `system` are affected and why (shared contracts, shared tools, hand-offs, "
49
+ "upstream/downstream roles, overlapping responsibilities), naming the specific "
50
+ "config_ids. Do NOT say 'impact unknown' when the diff is present, read it. Only list a "
51
+ "genuine unknown if it truly cannot be inferred from the provided data.\n"
52
+ "Return concise JSON with keys: summary (one concrete sentence), behavior_change (what "
53
+ "the agent will now do differently, specifically), blast_radius (which named configs or "
54
+ "consumers are affected and why), risk_level (low|medium|high), rollback_note, unknowns "
55
+ "(array of only real unknowns)."
56
+ ),
57
+ },
58
+ {"role": "user", "content": json_dumps(payload)},
59
+ ]
60
+ return await self.complete(
61
+ messages,
62
+ temperature=kwargs.get("temperature", 0.1),
63
+ max_tokens=kwargs.get("max_tokens", 1100),
64
+ )
65
+
66
+
67
+ class ProviderError(Exception):
68
+ def __init__(self, message: str, provider: str | None = None, model: str | None = None):
69
+ super().__init__(message)
70
+ self.provider = provider
71
+ self.model = model
72
+
73
+
74
+ class ProviderRateLimitError(ProviderError):
75
+ pass
76
+
77
+
78
+ class ProviderAuthError(ProviderError):
79
+ pass
@@ -0,0 +1,103 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """Claude provider for cfg-impact narration."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from cfg_impact.providers.base import (
13
+ BaseImpactProvider,
14
+ ProviderAuthError,
15
+ ProviderError,
16
+ ProviderRateLimitError,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ CLAUDE_API_BASE = "https://api.anthropic.com/v1"
22
+ ANTHROPIC_VERSION = "2023-06-01"
23
+
24
+
25
+ class ClaudeProvider(BaseImpactProvider):
26
+ def __init__(self, api_key: str | None = None, model: str | None = None, **kwargs: Any):
27
+ super().__init__(api_key=api_key or os.getenv("ANTHROPIC_API_KEY", ""), model=model, **kwargs)
28
+
29
+ @property
30
+ def provider_name(self) -> str:
31
+ return "claude"
32
+
33
+ @property
34
+ def default_model(self) -> str:
35
+ return os.getenv("CFGIT_CLAUDE_MODEL", "claude-sonnet-4-20250514")
36
+
37
+ async def complete(self, messages: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
38
+ formatted, system_prompt = _format_messages(messages)
39
+ payload: dict[str, Any] = {
40
+ "model": self.model,
41
+ "messages": formatted,
42
+ "max_tokens": kwargs.get("max_tokens", 900),
43
+ }
44
+ if system_prompt:
45
+ payload["system"] = system_prompt
46
+ if kwargs.get("temperature") is not None:
47
+ payload["temperature"] = kwargs["temperature"]
48
+
49
+ headers = {
50
+ "Content-Type": "application/json",
51
+ "x-api-key": self.api_key,
52
+ "anthropic-version": ANTHROPIC_VERSION,
53
+ }
54
+
55
+ try:
56
+ async with httpx.AsyncClient(timeout=90.0) as client:
57
+ response = await client.post(
58
+ f"{CLAUDE_API_BASE}/messages",
59
+ json=payload,
60
+ headers=headers,
61
+ )
62
+ if response.status_code == 401:
63
+ raise ProviderAuthError("invalid Anthropic API key", provider="claude")
64
+ if response.status_code == 429:
65
+ raise ProviderRateLimitError("Anthropic rate limit exceeded", provider="claude")
66
+ if response.status_code != 200:
67
+ raise ProviderError(
68
+ f"Claude API error ({response.status_code}): {response.text[:500]}",
69
+ provider="claude",
70
+ model=self.model,
71
+ )
72
+ data = response.json()
73
+ content = "".join(
74
+ block.get("text", "")
75
+ for block in data.get("content", [])
76
+ if block.get("type") == "text"
77
+ )
78
+ return {
79
+ "content": content,
80
+ "usage": data.get("usage") or {},
81
+ "model": data.get("model") or self.model,
82
+ "stop_reason": data.get("stop_reason"),
83
+ }
84
+ except ProviderError:
85
+ raise
86
+ except json.JSONDecodeError as exc:
87
+ raise ProviderError("Claude returned invalid JSON", provider="claude", model=self.model) from exc
88
+ except Exception as exc:
89
+ logger.error("Claude impact provider error: %s", exc)
90
+ raise ProviderError(str(exc), provider="claude", model=self.model) from exc
91
+
92
+
93
+ def _format_messages(messages: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], str | None]:
94
+ formatted: list[dict[str, Any]] = []
95
+ system_prompt = None
96
+ for msg in messages:
97
+ role = msg.get("role")
98
+ content = msg.get("content", "")
99
+ if role == "system":
100
+ system_prompt = content
101
+ continue
102
+ formatted.append({"role": "assistant" if role == "assistant" else "user", "content": content})
103
+ return formatted, system_prompt
@@ -0,0 +1,36 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """Factory for cfg-impact LLM providers."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from cfg_impact.providers.base import BaseImpactProvider
8
+ from cfg_impact.providers.claude import ClaudeProvider
9
+ from cfg_impact.providers.gemini import GeminiProvider
10
+ from cfg_impact.providers.openai_provider import OpenAIProvider
11
+
12
+
13
+ class ImpactProviderFactory:
14
+ _providers = {
15
+ "claude": ClaudeProvider,
16
+ "openai": OpenAIProvider,
17
+ "gemini": GeminiProvider,
18
+ "google": GeminiProvider,
19
+ }
20
+
21
+ @classmethod
22
+ def create_provider(
23
+ cls,
24
+ provider: str,
25
+ *,
26
+ model: str | None = None,
27
+ api_key: str | None = None,
28
+ **kwargs: Any,
29
+ ) -> BaseImpactProvider:
30
+ provider_name = provider.lower()
31
+ if provider_name not in cls._providers:
32
+ raise ValueError(
33
+ f"unsupported impact provider '{provider_name}'. "
34
+ f"Available: {', '.join(sorted(cls._providers))}"
35
+ )
36
+ return cls._providers[provider_name](api_key=api_key, model=model, **kwargs)
@@ -0,0 +1,105 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """Google Gemini provider for cfg-impact narration."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from cfg_impact.providers.base import (
13
+ BaseImpactProvider,
14
+ ProviderAuthError,
15
+ ProviderError,
16
+ ProviderRateLimitError,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"
22
+
23
+
24
+ class GeminiProvider(BaseImpactProvider):
25
+ def __init__(self, api_key: str | None = None, model: str | None = None, **kwargs: Any):
26
+ super().__init__(
27
+ api_key=api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY", ""),
28
+ model=model,
29
+ **kwargs,
30
+ )
31
+
32
+ @property
33
+ def provider_name(self) -> str:
34
+ return "gemini"
35
+
36
+ @property
37
+ def default_model(self) -> str:
38
+ return os.getenv("CFGIT_GEMINI_MODEL", "gemini-3.5-flash")
39
+
40
+ async def complete(self, messages: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
41
+ contents, system_instruction = _format_messages(messages)
42
+ payload: dict[str, Any] = {"contents": contents}
43
+ if system_instruction:
44
+ payload["system_instruction"] = {"parts": [{"text": system_instruction}]}
45
+ gen_config: dict[str, Any] = {}
46
+ if kwargs.get("temperature") is not None:
47
+ gen_config["temperature"] = kwargs["temperature"]
48
+ if kwargs.get("max_tokens") is not None:
49
+ gen_config["maxOutputTokens"] = kwargs["max_tokens"]
50
+ if gen_config:
51
+ payload["generationConfig"] = gen_config
52
+
53
+ url = f"{GEMINI_API_BASE}/models/{self.model}:generateContent"
54
+ headers = {"Content-Type": "application/json", "x-goog-api-key": self.api_key}
55
+
56
+ try:
57
+ async with httpx.AsyncClient(timeout=90.0) as client:
58
+ response = await client.post(url, json=payload, headers=headers)
59
+ if response.status_code in (401, 403):
60
+ raise ProviderAuthError("invalid Google API key", provider="gemini")
61
+ if response.status_code == 429:
62
+ raise ProviderRateLimitError("Gemini rate limit exceeded", provider="gemini")
63
+ if response.status_code != 200:
64
+ raise ProviderError(
65
+ f"Gemini API error ({response.status_code}): {response.text[:500]}",
66
+ provider="gemini",
67
+ model=self.model,
68
+ )
69
+ data = response.json()
70
+ candidate = (data.get("candidates") or [{}])[0]
71
+ parts = ((candidate.get("content") or {}).get("parts")) or []
72
+ content = "".join(part.get("text", "") for part in parts)
73
+ usage = data.get("usageMetadata") or {}
74
+ return {
75
+ "content": content,
76
+ "usage": {
77
+ "input_tokens": usage.get("promptTokenCount"),
78
+ "output_tokens": usage.get("candidatesTokenCount"),
79
+ "total_tokens": usage.get("totalTokenCount"),
80
+ },
81
+ "model": self.model,
82
+ "stop_reason": candidate.get("finishReason"),
83
+ }
84
+ except ProviderError:
85
+ raise
86
+ except json.JSONDecodeError as exc:
87
+ raise ProviderError("Gemini returned invalid JSON", provider="gemini", model=self.model) from exc
88
+ except Exception as exc:
89
+ logger.error("Gemini impact provider error: %s", exc)
90
+ raise ProviderError(str(exc), provider="gemini", model=self.model) from exc
91
+
92
+
93
+ def _format_messages(messages: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], str | None]:
94
+ contents: list[dict[str, Any]] = []
95
+ system_instruction = None
96
+ for msg in messages:
97
+ role = msg.get("role")
98
+ text = msg.get("content", "")
99
+ if role == "system":
100
+ system_instruction = text
101
+ continue
102
+ contents.append(
103
+ {"role": "model" if role == "assistant" else "user", "parts": [{"text": text}]}
104
+ )
105
+ return contents, system_instruction
@@ -0,0 +1,83 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """OpenAI provider for cfg-impact narration."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from cfg_impact.providers.base import (
13
+ BaseImpactProvider,
14
+ ProviderAuthError,
15
+ ProviderError,
16
+ ProviderRateLimitError,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ OPENAI_API_BASE = "https://api.openai.com/v1"
22
+
23
+
24
+ class OpenAIProvider(BaseImpactProvider):
25
+ def __init__(self, api_key: str | None = None, model: str | None = None, **kwargs: Any):
26
+ super().__init__(api_key=api_key or os.getenv("OPENAI_API_KEY", ""), model=model, **kwargs)
27
+
28
+ @property
29
+ def provider_name(self) -> str:
30
+ return "openai"
31
+
32
+ @property
33
+ def default_model(self) -> str:
34
+ return os.getenv("CFGIT_OPENAI_MODEL", "gpt-4o-mini")
35
+
36
+ async def complete(self, messages: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
37
+ payload: dict[str, Any] = {"model": self.model, "messages": messages}
38
+ if self.model.startswith(("o1", "o3")):
39
+ if kwargs.get("max_tokens") is not None:
40
+ payload["max_completion_tokens"] = kwargs["max_tokens"]
41
+ else:
42
+ if kwargs.get("temperature") is not None:
43
+ payload["temperature"] = kwargs["temperature"]
44
+ if kwargs.get("max_tokens") is not None:
45
+ payload["max_tokens"] = kwargs["max_tokens"]
46
+
47
+ headers = {
48
+ "Content-Type": "application/json",
49
+ "Authorization": f"Bearer {self.api_key}",
50
+ }
51
+
52
+ try:
53
+ async with httpx.AsyncClient(timeout=90.0) as client:
54
+ response = await client.post(
55
+ f"{OPENAI_API_BASE}/chat/completions",
56
+ json=payload,
57
+ headers=headers,
58
+ )
59
+ if response.status_code == 401:
60
+ raise ProviderAuthError("invalid OpenAI API key", provider="openai")
61
+ if response.status_code == 429:
62
+ raise ProviderRateLimitError("OpenAI rate limit exceeded", provider="openai")
63
+ if response.status_code != 200:
64
+ raise ProviderError(
65
+ f"OpenAI API error ({response.status_code}): {response.text[:500]}",
66
+ provider="openai",
67
+ model=self.model,
68
+ )
69
+ data = response.json()
70
+ choice = (data.get("choices") or [{}])[0]
71
+ return {
72
+ "content": ((choice.get("message") or {}).get("content") or ""),
73
+ "usage": data.get("usage") or {},
74
+ "model": data.get("model") or self.model,
75
+ "stop_reason": choice.get("finish_reason"),
76
+ }
77
+ except ProviderError:
78
+ raise
79
+ except json.JSONDecodeError as exc:
80
+ raise ProviderError("OpenAI returned invalid JSON", provider="openai", model=self.model) from exc
81
+ except Exception as exc:
82
+ logger.error("OpenAI impact provider error: %s", exc)
83
+ raise ProviderError(str(exc), provider="openai", model=self.model) from exc
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: cfgit-impact
3
+ Version: 0.1.0
4
+ Summary: Optional cfgit plugin for deterministic system-impact summaries and opt-in LLM narration of database record diffs
5
+ Project-URL: Homepage, https://github.com/AusafMo/cfgit
6
+ Project-URL: Repository, https://github.com/AusafMo/cfgit
7
+ Project-URL: Issues, https://github.com/AusafMo/cfgit/issues
8
+ Project-URL: Documentation, https://github.com/AusafMo/cfgit/tree/main/plugins/cfg_impact#readme
9
+ Author: Mohammad Ausaf
10
+ License-Expression: Apache-2.0
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: cfgit<0.2.0,>=0.1.0
13
+ Requires-Dist: httpx>=0.27
14
+ Description-Content-Type: text/markdown
15
+
16
+ # cfg-impact
17
+
18
+ Optional system-impact analysis for cfgit.
19
+
20
+ This package is deliberately outside `src/cfg/core/`. The cfgit core stays LLM-free and provider-free; this plugin owns both deterministic impact analysis and optional LLM narration.
21
+
22
+ ## Boundary
23
+
24
+ - `src/cfg/core/` never imports this plugin.
25
+ - `src/cfg/core/` never imports vendor SDKs or provider clients.
26
+ - The main action layer imports `cfg_impact.overview` only when `cfg impact` or the UI/MCP impact action is called.
27
+ - Provider selection comes from `[connections].ai_provider` unless a caller passes `provider`.
28
+
29
+ ## Provider Pattern
30
+
31
+ The provider layer uses a small factory pattern:
32
+
33
+ - `cfg_impact.providers.base.BaseImpactProvider`
34
+ - `cfg_impact.providers.factory.ImpactProviderFactory`
35
+ - `cfg_impact.providers.claude.ClaudeProvider`
36
+ - `cfg_impact.providers.openai_provider.OpenAIProvider`
37
+ - `cfg_impact.providers.gemini.GeminiProvider`
38
+
39
+ The impact engine calls `narrate()` or `complete()`. It never imports a vendor module directly.
40
+
41
+ ## Commands
42
+
43
+ Deterministic local analysis:
44
+
45
+ `cfg impact agent_configs:agent_planner =HEAD =live --json`
46
+
47
+ Opt-in LLM narration:
48
+
49
+ `cfg impact agent_configs:agent_planner =HEAD =live --llm --json`
50
+
51
+ Scope narration to selected records instead of the whole system:
52
+
53
+ `cfg impact agent_configs:agent_planner --against agent_configs:critic --against modelgarden_models:openai/gpt-4o-mini --llm --json`
54
+
55
+ LLM narration is refused unless the record is listed in
56
+ `[connections].share_with_ai`. The payload sent to the provider is bounded and
57
+ secret-stripped. It includes real before/after field diffs for the changed record,
58
+ plus only allowlisted text from related records. In scoped mode, only selected
59
+ records are included in the system map.
60
+
61
+ The plugin uses `ANTHROPIC_API_KEY` for `claude`, `OPENAI_API_KEY` for `openai`,
62
+ and `GEMINI_API_KEY` or `GOOGLE_API_KEY` for `gemini`.
@@ -0,0 +1,11 @@
1
+ cfg_impact/__init__.py,sha256=VVFVEyLyEZZOtK8ldG_r3dHCwD_BjdwXVDBXS0qz9ZM,298
2
+ cfg_impact/overview.py,sha256=sGUbdak5t1VDQPJ35fgGBULwrLwAc_mnByBFowU24kY,15998
3
+ cfg_impact/providers/__init__.py,sha256=Mj4gSv-eLceldwaNTPn8rYsvyD62qssm-HzVbDxBvhQ,345
4
+ cfg_impact/providers/base.py,sha256=wBjRScHPYfJGVBYRIQG30E4XjtbN07O75NCIGg5V9KA,3361
5
+ cfg_impact/providers/claude.py,sha256=RAviXGgPXF04O0EzUQG1qviN2rUl9O9i_lJXndQHEvw,3751
6
+ cfg_impact/providers/factory.py,sha256=NlM3s4wAWySKL7gnAkE9rfYrGHV4VO54-Rnhn6Dn4rc,1171
7
+ cfg_impact/providers/gemini.py,sha256=dC3ivJjGv1aNYLti6Dnh4UUTVmTfPNIc3LYUGDftI0E,4155
8
+ cfg_impact/providers/openai_provider.py,sha256=97eS4gWL_PLLL9UgiI11mo58MBM2t-toBvsQzPREeuU,3180
9
+ cfgit_impact-0.1.0.dist-info/METADATA,sha256=49qqC3baiLcqCeT6Yw7L1xZjNfI7TGwSX1aJ5RhY8oU,2501
10
+ cfgit_impact-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ cfgit_impact-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any