contextops 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.
contextops/cli/main.py ADDED
@@ -0,0 +1,320 @@
1
+ """
2
+ ContextOps CLI.
3
+
4
+ Commands:
5
+ contextops inspect <file> — Analyze a context file and display results
6
+ contextops check <file> — CI mode: fail if score below threshold
7
+ contextops demo — Run analysis on a built-in demo context
8
+
9
+ The CLI is a view layer. JSON is the source of truth.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import click
19
+
20
+ from contextops.api.inspect import inspect_context
21
+ from contextops.api.stability import run_stability_report
22
+ from contextops.api.diff import diff_contexts
23
+ from contextops.cli.renderer import render, render_stability, render_diff
24
+ from contextops.core.config import ContextOpsConfig
25
+
26
+
27
+ def _build_config(
28
+ config_file: str | None,
29
+ retrieval_max_ratio: float | None,
30
+ system_max_ratio: float | None,
31
+ memory_max_ratio: float | None,
32
+ tool_max_ratio: float | None,
33
+ ) -> ContextOpsConfig:
34
+ if config_file:
35
+ config = ContextOpsConfig.from_file(config_file)
36
+ else:
37
+ config = ContextOpsConfig.default()
38
+
39
+ # CLI overrides
40
+ has_override = False
41
+ if retrieval_max_ratio is not None:
42
+ config.retrieval_max_ratio = retrieval_max_ratio
43
+ has_override = True
44
+ if system_max_ratio is not None:
45
+ config.system_max_ratio = system_max_ratio
46
+ has_override = True
47
+ if memory_max_ratio is not None:
48
+ config.memory_max_ratio = memory_max_ratio
49
+ has_override = True
50
+ if tool_max_ratio is not None:
51
+ config.tool_max_ratio = tool_max_ratio
52
+ has_override = True
53
+
54
+ if has_override:
55
+ config.mode = "custom"
56
+
57
+ return config
58
+
59
+
60
+ @click.group()
61
+ @click.version_option(version="0.1.0", prog_name="contextops")
62
+ def cli() -> None:
63
+ """ContextOps — Context observability for LLM applications."""
64
+ pass
65
+
66
+
67
+ @cli.command()
68
+ @click.argument("file", type=click.Path(exists=True), required=False)
69
+ @click.option("--json-output", is_flag=True, help="Output raw JSON instead of pretty format")
70
+ @click.option("--model", default="gpt-4o", help="Model for token encoding (default: gpt-4o)")
71
+ @click.option("--config", help="Path to JSON config file")
72
+ @click.option("--retrieval-max-ratio", type=float, help="Override retrieval max ratio")
73
+ @click.option("--system-max-ratio", type=float, help="Override system max ratio")
74
+ @click.option("--memory-max-ratio", type=float, help="Override memory max ratio")
75
+ @click.option("--tool-max-ratio", type=float, help="Override tool max ratio")
76
+ @click.option("--explain", is_flag=True, help="Show detailed reasoning for the penalties (Top Score Drivers)")
77
+ def inspect(
78
+ file: str | None,
79
+ json_output: bool,
80
+ model: str,
81
+ config: str | None,
82
+ retrieval_max_ratio: float | None,
83
+ system_max_ratio: float | None,
84
+ memory_max_ratio: float | None,
85
+ tool_max_ratio: float | None,
86
+ explain: bool,
87
+ ) -> None:
88
+ """Analyze a context file and display results.
89
+
90
+ FILE should be a JSON file containing LLM context in any supported format:
91
+ - OpenAI message list
92
+ - Structured dict with system/messages/chunks/memory/tools keys
93
+ """
94
+ cfg = _build_config(
95
+ config, retrieval_max_ratio, system_max_ratio, memory_max_ratio, tool_max_ratio
96
+ )
97
+
98
+ raw_input: dict | list
99
+ if file is None:
100
+ # No file provided — run demo
101
+ raw_input = _get_demo_context()
102
+ click.echo(click.style("\n [i] No file provided - running demo context\n", fg="cyan"))
103
+ else:
104
+ raw_input = _load_file(file)
105
+
106
+ result = inspect_context(raw_input, model=model, config=cfg)
107
+ output = render(result, use_json=json_output, explain=explain)
108
+ _safe_echo(output)
109
+
110
+
111
+ @cli.command()
112
+ @click.argument("file", type=click.Path(exists=True))
113
+ @click.option("--min-score", type=int, required=True, help="Minimum passing score (0-100)")
114
+ @click.option("--model", default="gpt-4o", help="Model for token encoding (default: gpt-4o)")
115
+ @click.option("--json-output", is_flag=True, help="Output JSON instead of pretty format")
116
+ @click.option("--config", help="Path to JSON config file")
117
+ @click.option("--retrieval-max-ratio", type=float, help="Override retrieval max ratio")
118
+ @click.option("--system-max-ratio", type=float, help="Override system max ratio")
119
+ @click.option("--memory-max-ratio", type=float, help="Override memory max ratio")
120
+ @click.option("--tool-max-ratio", type=float, help="Override tool max ratio")
121
+ @click.option("--explain", is_flag=True, help="Show detailed reasoning for the penalties (Top Score Drivers)")
122
+ def check(
123
+ file: str,
124
+ min_score: int,
125
+ model: str,
126
+ json_output: bool,
127
+ config: str | None,
128
+ retrieval_max_ratio: float | None,
129
+ system_max_ratio: float | None,
130
+ memory_max_ratio: float | None,
131
+ tool_max_ratio: float | None,
132
+ explain: bool,
133
+ ) -> None:
134
+ """CI mode: fail if context score is below threshold.
135
+
136
+ Returns exit code 0 if score >= min_score, exit code 1 otherwise.
137
+ Designed for CI/CD pipelines.
138
+
139
+ Example:
140
+ contextops check context.json --min-score 70
141
+ """
142
+ cfg = _build_config(
143
+ config, retrieval_max_ratio, system_max_ratio, memory_max_ratio, tool_max_ratio
144
+ )
145
+
146
+ raw_input = _load_file(file)
147
+ result = inspect_context(raw_input, model=model, config=cfg)
148
+
149
+ if json_output:
150
+ output = render(result, use_json=True, explain=explain)
151
+ else:
152
+ output = render(result, use_json=False, explain=explain)
153
+
154
+ click.echo(output)
155
+
156
+ if result.score >= min_score:
157
+ click.echo(click.style(
158
+ f"\n PASS: score {result.score} >= {min_score}\n",
159
+ fg="green", bold=True
160
+ ))
161
+ sys.exit(0)
162
+ else:
163
+ click.echo(click.style(
164
+ f"\n FAIL: score {result.score} < {min_score}\n",
165
+ fg="red", bold=True
166
+ ))
167
+ sys.exit(1)
168
+
169
+
170
+ @cli.command()
171
+ @click.option("--json-output", is_flag=True, help="Output raw JSON")
172
+ def demo(json_output: bool) -> None:
173
+ """Run analysis on a built-in demo context that shows the wow moment."""
174
+ raw_input = _get_demo_context()
175
+ result = inspect_context(raw_input)
176
+ output = render(result, use_json=json_output, explain=False)
177
+ _safe_echo(output)
178
+
179
+
180
+ @cli.command()
181
+ @click.argument("file", type=click.Path(exists=True), required=False)
182
+ def stability(file: str | None) -> None:
183
+ """Run a deterministic stability report on the context scoring engine."""
184
+ raw_input: dict | list
185
+ if file is None:
186
+ raw_input = _get_demo_context()
187
+ click.echo(click.style("\n [i] No file provided - running demo context\n", fg="cyan"))
188
+ else:
189
+ raw_input = _load_file(file)
190
+
191
+ report = run_stability_report(raw_input)
192
+ output = render_stability(report)
193
+ _safe_echo(output)
194
+
195
+
196
+ @cli.command()
197
+ @click.argument("file_a", type=click.Path(exists=True))
198
+ @click.argument("file_b", type=click.Path(exists=True))
199
+ def diff(file_a: str, file_b: str) -> None:
200
+ """Compare two context analysis outputs to visualize changes."""
201
+ raw_a = _load_file(file_a)
202
+ raw_b = _load_file(file_b)
203
+
204
+ result = diff_contexts(raw_a, raw_b)
205
+ output = render_diff(result)
206
+ _safe_echo(output)
207
+
208
+
209
+ # ── Helpers ─────────────────────────────────────────────────────────────
210
+
211
+
212
+ def _load_file(filepath: str) -> dict | list:
213
+ """Load a JSON context file."""
214
+ path = Path(filepath)
215
+ try:
216
+ with open(path, "r", encoding="utf-8") as f:
217
+ data = json.load(f)
218
+ except json.JSONDecodeError as e:
219
+ click.echo(click.style(f"Error: Invalid JSON in {filepath}: {e}", fg="red"), err=True)
220
+ sys.exit(2)
221
+ except Exception as e:
222
+ click.echo(click.style(f"Error reading {filepath}: {e}", fg="red"), err=True)
223
+ sys.exit(2)
224
+ return data
225
+
226
+
227
+ def _safe_echo(text: str) -> None:
228
+ """Print text handling Windows console encoding issues."""
229
+ try:
230
+ click.echo(text)
231
+ except UnicodeEncodeError:
232
+ # Fallback: replace unencodable chars
233
+ encoded = text.encode(sys.stdout.encoding or "utf-8", errors="replace")
234
+ sys.stdout.buffer.write(encoded)
235
+ sys.stdout.buffer.write(b"\n")
236
+ sys.stdout.buffer.flush()
237
+
238
+
239
+ def _get_demo_context() -> dict:
240
+ """
241
+ Built-in demo context designed to trigger the "wow moment".
242
+
243
+ This simulates a real-world RAG pipeline with:
244
+ - A bloated system prompt
245
+ - Redundant retrieval chunks (near-duplicates)
246
+ - Memory that overlaps with retrieval
247
+ - Low source diversity (all chunks from the same doc)
248
+ """
249
+ return {
250
+ "system": (
251
+ "You are a helpful AI assistant. You must always be polite and professional. "
252
+ "You must always follow the user's instructions carefully. "
253
+ "You must never generate harmful content. "
254
+ "You must always provide accurate and helpful responses. "
255
+ "Please ensure you follow all guidelines at all times. "
256
+ "Remember to always be helpful and follow instructions. "
257
+ "Important: always respond in a structured format. "
258
+ "Note: ensure your responses are accurate and well-formatted. "
259
+ "Guidelines: follow all rules and provide quality answers. "
260
+ "Rules: be professional, be accurate, be helpful at all times."
261
+ ),
262
+ "messages": [
263
+ {"role": "user", "content": "What is the refund policy for premium subscriptions?"},
264
+ ],
265
+ "chunks": [
266
+ {
267
+ "content": (
268
+ "Refund Policy: Premium subscriptions can be refunded within 30 days "
269
+ "of purchase. To request a refund, contact support@example.com with "
270
+ "your order number. Refunds are processed within 5-7 business days."
271
+ ),
272
+ "source": "support_docs/refund_policy.md",
273
+ },
274
+ {
275
+ "content": (
276
+ "Premium Subscription Refund: You may request a refund for premium "
277
+ "subscriptions within 30 days of purchase. Send your order number to "
278
+ "support@example.com. Processing takes 5-7 business days."
279
+ ),
280
+ "source": "support_docs/refund_policy.md",
281
+ },
282
+ {
283
+ "content": (
284
+ "Our refund policy allows premium subscription holders to get a full "
285
+ "refund within 30 days. Contact support@example.com with your order "
286
+ "number for processing. Expect 5-7 business days for completion."
287
+ ),
288
+ "source": "support_docs/refund_policy.md",
289
+ },
290
+ {
291
+ "content": (
292
+ "How to get a refund: If you have a premium subscription, you can "
293
+ "request a refund within 30 days of purchase. Email support@example.com "
294
+ "with your order number. Refunds take 5-7 business days."
295
+ ),
296
+ "source": "support_docs/refund_policy.md",
297
+ },
298
+ {
299
+ "content": (
300
+ "Pricing: Premium subscriptions cost $29.99/month or $299/year. "
301
+ "Enterprise plans start at $99/month. Contact sales for custom pricing."
302
+ ),
303
+ "source": "support_docs/pricing.md",
304
+ },
305
+ ],
306
+ "memory": [
307
+ (
308
+ "User previously asked about premium subscription pricing on 2024-01-15. "
309
+ "User was interested in the annual plan. Refund policy was mentioned briefly."
310
+ ),
311
+ (
312
+ "User asked about the refund policy for premium subscriptions. "
313
+ "Support documents indicate 30-day refund window. Contact support@example.com."
314
+ ),
315
+ ],
316
+ }
317
+
318
+
319
+ if __name__ == "__main__":
320
+ cli()