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 +6 -0
- cfg_impact/overview.py +432 -0
- cfg_impact/providers/__init__.py +7 -0
- cfg_impact/providers/base.py +79 -0
- cfg_impact/providers/claude.py +103 -0
- cfg_impact/providers/factory.py +36 -0
- cfg_impact/providers/gemini.py +105 -0
- cfg_impact/providers/openai_provider.py +83 -0
- cfgit_impact-0.1.0.dist-info/METADATA +62 -0
- cfgit_impact-0.1.0.dist-info/RECORD +11 -0
- cfgit_impact-0.1.0.dist-info/WHEEL +4 -0
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,,
|