bridgekit 0.3.7__tar.gz → 0.3.9__tar.gz
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.
- {bridgekit-0.3.7 → bridgekit-0.3.9}/PKG-INFO +78 -3
- {bridgekit-0.3.7 → bridgekit-0.3.9}/README.md +77 -2
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit/__init__.py +3 -2
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit/cli.py +1 -1
- bridgekit-0.3.9/bridgekit/compare.py +128 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit/config.py +1 -1
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit/planner.py +3 -2
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit/redteam.py +3 -2
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit/reviewer.py +3 -2
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit/search.py +3 -2
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit.egg-info/PKG-INFO +78 -3
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit.egg-info/SOURCES.txt +3 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/pyproject.toml +1 -1
- bridgekit-0.3.9/tests/test_cli.py +175 -0
- bridgekit-0.3.9/tests/test_compare.py +275 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/tests/test_planner.py +30 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/tests/test_redteam.py +30 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/tests/test_reviewer.py +30 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/tests/test_search.py +42 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/LICENSE +0 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit/providers.py +0 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit.egg-info/dependency_links.txt +0 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit.egg-info/entry_points.txt +0 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit.egg-info/requires.txt +0 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/bridgekit.egg-info/top_level.txt +0 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/setup.cfg +0 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/tests/test_config.py +0 -0
- {bridgekit-0.3.7 → bridgekit-0.3.9}/tests/test_providers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bridgekit
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.9
|
|
4
4
|
Summary: AI tools that make you a better data scientist, not a redundant one.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://usebridgekit.com
|
|
@@ -136,6 +136,9 @@ onboarding users to reporting as a growth lever.
|
|
|
136
136
|
"""
|
|
137
137
|
|
|
138
138
|
print(evaluate(text))
|
|
139
|
+
|
|
140
|
+
# Override for longer analyses
|
|
141
|
+
print(evaluate(text, max_tokens=2048))
|
|
139
142
|
```
|
|
140
143
|
|
|
141
144
|
**Output:**
|
|
@@ -190,6 +193,9 @@ Supports `.txt`, `.md`, `.pdf`, `.docx`, `.pptx`, and `.ipynb` files.
|
|
|
190
193
|
from bridgekit import ask
|
|
191
194
|
|
|
192
195
|
print(ask("what drove churn in Q3?", source="reports/"))
|
|
196
|
+
|
|
197
|
+
# Override for longer responses
|
|
198
|
+
print(ask("what drove churn in Q3?", source="reports/", max_tokens=2048))
|
|
193
199
|
```
|
|
194
200
|
|
|
195
201
|
**From raw text:**
|
|
@@ -234,7 +240,7 @@ print(plan(
|
|
|
234
240
|
))
|
|
235
241
|
```
|
|
236
242
|
|
|
237
|
-
`data_description` and `
|
|
243
|
+
`data_description`, `goal`, and `max_tokens` are optional — the more context you provide, the more tailored the recommendation.
|
|
238
244
|
|
|
239
245
|
**`goal` examples:** `"causal inference"`, `"prediction"`, `"segmentation"`, `"hypothesis testing"`, `"exploration"`
|
|
240
246
|
|
|
@@ -297,6 +303,9 @@ print(redteam(text))
|
|
|
297
303
|
# Or specify a stakeholder
|
|
298
304
|
print(redteam(text, stakeholder="VP of Engineering"))
|
|
299
305
|
print(redteam(text, stakeholder="VP of Marketing"))
|
|
306
|
+
|
|
307
|
+
# Override for longer responses
|
|
308
|
+
print(redteam(text, max_tokens=2048))
|
|
300
309
|
```
|
|
301
310
|
|
|
302
311
|
Same writeup, different attack angles:
|
|
@@ -387,6 +396,72 @@ willing to commit to — and what's your confidence interval on that estimate?"
|
|
|
387
396
|
|
|
388
397
|
---
|
|
389
398
|
|
|
399
|
+
## Tool #5: Compare
|
|
400
|
+
|
|
401
|
+
Run the same tool through two providers and see both outputs side by side. Useful for evaluating which model works best for your use case — as a one-liner.
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
from bridgekit import compare
|
|
405
|
+
|
|
406
|
+
text = """
|
|
407
|
+
I analyzed 90 days of user behavior data to understand what drives subscription
|
|
408
|
+
upgrades. Users who engaged with the reporting feature within their first week
|
|
409
|
+
were 3x more likely to upgrade within 30 days. I recommend we prioritize
|
|
410
|
+
onboarding users to reporting as a growth lever.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
# Compare evaluate across Anthropic and OpenAI (default)
|
|
414
|
+
print(compare(text, tool="evaluate"))
|
|
415
|
+
|
|
416
|
+
# Compare plan across two providers
|
|
417
|
+
print(compare("Did our onboarding flow reduce churn?", tool="plan"))
|
|
418
|
+
|
|
419
|
+
# Compare redteam with a specific stakeholder
|
|
420
|
+
print(compare(text, tool="redteam", stakeholder="VP of Finance"))
|
|
421
|
+
|
|
422
|
+
# Override models for each provider
|
|
423
|
+
print(compare(text, model_a="claude-haiku-4-5-20251001", model_b="gpt-4-turbo"))
|
|
424
|
+
|
|
425
|
+
# Compare two specific providers
|
|
426
|
+
print(compare(text, providers=["anthropic", "gemini"]))
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
**Parameters:**
|
|
430
|
+
- `tool` - which tool to run: `"evaluate"`, `"plan"`, or `"redteam"` (defaults to `"evaluate"`)
|
|
431
|
+
- `providers` - list of exactly two providers to compare (defaults to `["anthropic", "openai"]`)
|
|
432
|
+
- `model_a`, `model_b` - optional model overrides for the first and second provider
|
|
433
|
+
- `**kwargs` - additional arguments passed through to the underlying tool (e.g. `max_tokens`, `stakeholder`, `data_description`)
|
|
434
|
+
|
|
435
|
+
**Output:**
|
|
436
|
+
```
|
|
437
|
+
BRIDGEKIT COMPARE: EVALUATE
|
|
438
|
+
─────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
ANTHROPIC claude-opus-4-8
|
|
441
|
+
─────────────────────────────────────────
|
|
442
|
+
BRIDGEKIT ANALYSIS REVIEW
|
|
443
|
+
─────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
1. CLARITY
|
|
446
|
+
✅ STRONG — Clean and jargon-free.
|
|
447
|
+
|
|
448
|
+
...
|
|
449
|
+
|
|
450
|
+
OPENAI gpt-4o
|
|
451
|
+
─────────────────────────────────────────
|
|
452
|
+
BRIDGEKIT ANALYSIS REVIEW
|
|
453
|
+
─────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
1. CLARITY
|
|
456
|
+
⚠️ NEEDS WORK — The phrase "engagement feature" needs more context.
|
|
457
|
+
|
|
458
|
+
...
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Both providers are called in parallel, so the total wait time is the slower of the two — not the sum.
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
390
465
|
## Multi-Provider Support
|
|
391
466
|
|
|
392
467
|
Bridgekit now supports multiple AI providers so you're not locked into one API. You can use Anthropic, OpenAI, or Google Gemini models with any tool.
|
|
@@ -414,7 +489,7 @@ Bridgekit automatically detects the provider from model names:
|
|
|
414
489
|
- Models starting with "gemini" → Google Gemini
|
|
415
490
|
|
|
416
491
|
**Default models by provider:**
|
|
417
|
-
- Anthropic: `claude-
|
|
492
|
+
- Anthropic: `claude-opus-4-8`
|
|
418
493
|
- OpenAI: `gpt-4o`
|
|
419
494
|
- Gemini: `gemini-1.5-pro`
|
|
420
495
|
|
|
@@ -104,6 +104,9 @@ onboarding users to reporting as a growth lever.
|
|
|
104
104
|
"""
|
|
105
105
|
|
|
106
106
|
print(evaluate(text))
|
|
107
|
+
|
|
108
|
+
# Override for longer analyses
|
|
109
|
+
print(evaluate(text, max_tokens=2048))
|
|
107
110
|
```
|
|
108
111
|
|
|
109
112
|
**Output:**
|
|
@@ -158,6 +161,9 @@ Supports `.txt`, `.md`, `.pdf`, `.docx`, `.pptx`, and `.ipynb` files.
|
|
|
158
161
|
from bridgekit import ask
|
|
159
162
|
|
|
160
163
|
print(ask("what drove churn in Q3?", source="reports/"))
|
|
164
|
+
|
|
165
|
+
# Override for longer responses
|
|
166
|
+
print(ask("what drove churn in Q3?", source="reports/", max_tokens=2048))
|
|
161
167
|
```
|
|
162
168
|
|
|
163
169
|
**From raw text:**
|
|
@@ -202,7 +208,7 @@ print(plan(
|
|
|
202
208
|
))
|
|
203
209
|
```
|
|
204
210
|
|
|
205
|
-
`data_description` and `
|
|
211
|
+
`data_description`, `goal`, and `max_tokens` are optional — the more context you provide, the more tailored the recommendation.
|
|
206
212
|
|
|
207
213
|
**`goal` examples:** `"causal inference"`, `"prediction"`, `"segmentation"`, `"hypothesis testing"`, `"exploration"`
|
|
208
214
|
|
|
@@ -265,6 +271,9 @@ print(redteam(text))
|
|
|
265
271
|
# Or specify a stakeholder
|
|
266
272
|
print(redteam(text, stakeholder="VP of Engineering"))
|
|
267
273
|
print(redteam(text, stakeholder="VP of Marketing"))
|
|
274
|
+
|
|
275
|
+
# Override for longer responses
|
|
276
|
+
print(redteam(text, max_tokens=2048))
|
|
268
277
|
```
|
|
269
278
|
|
|
270
279
|
Same writeup, different attack angles:
|
|
@@ -355,6 +364,72 @@ willing to commit to — and what's your confidence interval on that estimate?"
|
|
|
355
364
|
|
|
356
365
|
---
|
|
357
366
|
|
|
367
|
+
## Tool #5: Compare
|
|
368
|
+
|
|
369
|
+
Run the same tool through two providers and see both outputs side by side. Useful for evaluating which model works best for your use case — as a one-liner.
|
|
370
|
+
|
|
371
|
+
```python
|
|
372
|
+
from bridgekit import compare
|
|
373
|
+
|
|
374
|
+
text = """
|
|
375
|
+
I analyzed 90 days of user behavior data to understand what drives subscription
|
|
376
|
+
upgrades. Users who engaged with the reporting feature within their first week
|
|
377
|
+
were 3x more likely to upgrade within 30 days. I recommend we prioritize
|
|
378
|
+
onboarding users to reporting as a growth lever.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
# Compare evaluate across Anthropic and OpenAI (default)
|
|
382
|
+
print(compare(text, tool="evaluate"))
|
|
383
|
+
|
|
384
|
+
# Compare plan across two providers
|
|
385
|
+
print(compare("Did our onboarding flow reduce churn?", tool="plan"))
|
|
386
|
+
|
|
387
|
+
# Compare redteam with a specific stakeholder
|
|
388
|
+
print(compare(text, tool="redteam", stakeholder="VP of Finance"))
|
|
389
|
+
|
|
390
|
+
# Override models for each provider
|
|
391
|
+
print(compare(text, model_a="claude-haiku-4-5-20251001", model_b="gpt-4-turbo"))
|
|
392
|
+
|
|
393
|
+
# Compare two specific providers
|
|
394
|
+
print(compare(text, providers=["anthropic", "gemini"]))
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**Parameters:**
|
|
398
|
+
- `tool` - which tool to run: `"evaluate"`, `"plan"`, or `"redteam"` (defaults to `"evaluate"`)
|
|
399
|
+
- `providers` - list of exactly two providers to compare (defaults to `["anthropic", "openai"]`)
|
|
400
|
+
- `model_a`, `model_b` - optional model overrides for the first and second provider
|
|
401
|
+
- `**kwargs` - additional arguments passed through to the underlying tool (e.g. `max_tokens`, `stakeholder`, `data_description`)
|
|
402
|
+
|
|
403
|
+
**Output:**
|
|
404
|
+
```
|
|
405
|
+
BRIDGEKIT COMPARE: EVALUATE
|
|
406
|
+
─────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
ANTHROPIC claude-opus-4-8
|
|
409
|
+
─────────────────────────────────────────
|
|
410
|
+
BRIDGEKIT ANALYSIS REVIEW
|
|
411
|
+
─────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
1. CLARITY
|
|
414
|
+
✅ STRONG — Clean and jargon-free.
|
|
415
|
+
|
|
416
|
+
...
|
|
417
|
+
|
|
418
|
+
OPENAI gpt-4o
|
|
419
|
+
─────────────────────────────────────────
|
|
420
|
+
BRIDGEKIT ANALYSIS REVIEW
|
|
421
|
+
─────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
1. CLARITY
|
|
424
|
+
⚠️ NEEDS WORK — The phrase "engagement feature" needs more context.
|
|
425
|
+
|
|
426
|
+
...
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Both providers are called in parallel, so the total wait time is the slower of the two — not the sum.
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
358
433
|
## Multi-Provider Support
|
|
359
434
|
|
|
360
435
|
Bridgekit now supports multiple AI providers so you're not locked into one API. You can use Anthropic, OpenAI, or Google Gemini models with any tool.
|
|
@@ -382,7 +457,7 @@ Bridgekit automatically detects the provider from model names:
|
|
|
382
457
|
- Models starting with "gemini" → Google Gemini
|
|
383
458
|
|
|
384
459
|
**Default models by provider:**
|
|
385
|
-
- Anthropic: `claude-
|
|
460
|
+
- Anthropic: `claude-opus-4-8`
|
|
386
461
|
- OpenAI: `gpt-4o`
|
|
387
462
|
- Gemini: `gemini-1.5-pro`
|
|
388
463
|
|
|
@@ -2,6 +2,7 @@ from .reviewer import evaluate
|
|
|
2
2
|
from .search import ask
|
|
3
3
|
from .planner import plan
|
|
4
4
|
from .redteam import redteam
|
|
5
|
+
from .compare import compare
|
|
5
6
|
|
|
6
|
-
__version__ = "0.3.
|
|
7
|
-
__all__ = ["evaluate", "ask", "plan", "redteam"]
|
|
7
|
+
__version__ = "0.3.9"
|
|
8
|
+
__all__ = ["evaluate", "ask", "plan", "redteam", "compare"]
|
|
@@ -9,7 +9,7 @@ from .search import ask
|
|
|
9
9
|
|
|
10
10
|
def _add_provider_args(parser: argparse.ArgumentParser) -> None:
|
|
11
11
|
parser.add_argument("--provider", help='AI provider: "anthropic", "openai", or "gemini"')
|
|
12
|
-
parser.add_argument("--model", help="Specific model to use (e.g. claude-opus-4-
|
|
12
|
+
parser.add_argument("--model", help="Specific model to use (e.g. claude-opus-4-8, gpt-4o)")
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def _cmd_plan(args: argparse.Namespace) -> None:
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
2
|
+
from .config import parse_provider, get_default_model, Provider
|
|
3
|
+
from .providers import create_message
|
|
4
|
+
|
|
5
|
+
SUPPORTED_TOOLS = ["evaluate", "plan", "redteam"]
|
|
6
|
+
|
|
7
|
+
SYNTHESIS_PROMPT = """You are comparing two AI-generated outputs for the same analysis task.
|
|
8
|
+
|
|
9
|
+
Your job is to write a short, direct summary (4-6 sentences) covering:
|
|
10
|
+
- Where both outputs agreed
|
|
11
|
+
- Where they differed — including any cases where one rated a dimension more harshly than the other
|
|
12
|
+
- Which output gave more specific or actionable feedback, and why
|
|
13
|
+
|
|
14
|
+
Be concrete. Reference specific dimensions or findings. No fluff."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_tool_fn(tool_name: str):
|
|
18
|
+
if tool_name == "evaluate":
|
|
19
|
+
from .reviewer import evaluate
|
|
20
|
+
return evaluate
|
|
21
|
+
elif tool_name == "plan":
|
|
22
|
+
from .planner import plan
|
|
23
|
+
return plan
|
|
24
|
+
elif tool_name == "redteam":
|
|
25
|
+
from .redteam import redteam
|
|
26
|
+
return redteam
|
|
27
|
+
raise ValueError(f"Unknown tool: {tool_name!r}. Supported tools: {SUPPORTED_TOOLS}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _call_tool(tool_name: str, text: str, provider: str, model, kwargs: dict) -> str:
|
|
31
|
+
fn = _get_tool_fn(tool_name)
|
|
32
|
+
if tool_name == "plan":
|
|
33
|
+
return fn(question=text, provider=provider, model=model, **kwargs)
|
|
34
|
+
return fn(text=text, provider=provider, model=model, **kwargs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _synthesize(results: list) -> str:
|
|
38
|
+
(provider_a, model_a, output_a), (provider_b, model_b, output_b) = results
|
|
39
|
+
user_message = (
|
|
40
|
+
f"OUTPUT 1 ({provider_a.upper()} / {model_a}):\n{output_a}\n\n"
|
|
41
|
+
f"OUTPUT 2 ({provider_b.upper()} / {model_b}):\n{output_b}"
|
|
42
|
+
)
|
|
43
|
+
return create_message(
|
|
44
|
+
provider=Provider.ANTHROPIC,
|
|
45
|
+
system_prompt=SYNTHESIS_PROMPT,
|
|
46
|
+
user_message=user_message,
|
|
47
|
+
max_tokens=512,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _format_output(tool: str, summary: str, results: list) -> str:
|
|
52
|
+
divider = "─" * 41
|
|
53
|
+
lines = [
|
|
54
|
+
f"BRIDGEKIT COMPARE: {tool.upper()}",
|
|
55
|
+
divider,
|
|
56
|
+
"",
|
|
57
|
+
"SUMMARY",
|
|
58
|
+
divider,
|
|
59
|
+
summary,
|
|
60
|
+
"",
|
|
61
|
+
"",
|
|
62
|
+
]
|
|
63
|
+
for i, (provider_name, model, output) in enumerate(results):
|
|
64
|
+
lines.append(f"{provider_name.upper()} {model}")
|
|
65
|
+
lines.append(divider)
|
|
66
|
+
lines.append(output)
|
|
67
|
+
if i < len(results) - 1:
|
|
68
|
+
lines.append("")
|
|
69
|
+
lines.append("")
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def compare(
|
|
74
|
+
text: str,
|
|
75
|
+
tool: str = "evaluate",
|
|
76
|
+
providers: list = None,
|
|
77
|
+
model_a: str = None,
|
|
78
|
+
model_b: str = None,
|
|
79
|
+
**kwargs
|
|
80
|
+
) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Run the same tool through two providers and return both outputs with a summary.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
text: The input text or question to analyze.
|
|
86
|
+
tool: Which tool to run: "evaluate", "plan", or "redteam". Defaults to "evaluate".
|
|
87
|
+
providers: List of exactly two providers to compare. Defaults to ["anthropic", "openai"].
|
|
88
|
+
model_a: Optional model override for the first provider.
|
|
89
|
+
model_b: Optional model override for the second provider.
|
|
90
|
+
**kwargs: Additional arguments passed through to the underlying tool
|
|
91
|
+
(e.g. max_tokens, stakeholder for redteam, data_description/goal for plan).
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A summary of key differences followed by both full outputs with provider headers.
|
|
95
|
+
"""
|
|
96
|
+
if not text or not text.strip():
|
|
97
|
+
raise ValueError("Text cannot be empty.")
|
|
98
|
+
|
|
99
|
+
if tool not in SUPPORTED_TOOLS:
|
|
100
|
+
raise ValueError(f"Unknown tool: {tool!r}. Supported tools: {SUPPORTED_TOOLS}")
|
|
101
|
+
|
|
102
|
+
if providers is None:
|
|
103
|
+
providers = ["anthropic", "openai"]
|
|
104
|
+
|
|
105
|
+
if len(providers) != 2:
|
|
106
|
+
raise ValueError(f"providers must contain exactly 2 providers, got {len(providers)}.")
|
|
107
|
+
|
|
108
|
+
models = [model_a, model_b]
|
|
109
|
+
resolved_models = []
|
|
110
|
+
for provider, model in zip(providers, models):
|
|
111
|
+
provider_enum = parse_provider(provider)
|
|
112
|
+
resolved_models.append(model if model else get_default_model(provider_enum))
|
|
113
|
+
|
|
114
|
+
results_map = {}
|
|
115
|
+
|
|
116
|
+
def run_one(idx):
|
|
117
|
+
output = _call_tool(tool, text, providers[idx], models[idx], kwargs)
|
|
118
|
+
return idx, providers[idx], resolved_models[idx], output
|
|
119
|
+
|
|
120
|
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
121
|
+
futures = {executor.submit(run_one, i): i for i in range(2)}
|
|
122
|
+
for future in as_completed(futures):
|
|
123
|
+
idx, provider, model, output = future.result()
|
|
124
|
+
results_map[idx] = (provider, model, output)
|
|
125
|
+
|
|
126
|
+
results = [results_map[0], results_map[1]]
|
|
127
|
+
summary = _synthesize(results)
|
|
128
|
+
return _format_output(tool, summary, results)
|
|
@@ -29,7 +29,7 @@ ALTERNATIVES
|
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def plan(question: str, data_description: str = None, goal: str = None, provider: str = None, model: str = None, system_prompt: str = None) -> str:
|
|
32
|
+
def plan(question: str, data_description: str = None, goal: str = None, provider: str = None, model: str = None, system_prompt: str = None, max_tokens: int = 1024) -> str:
|
|
33
33
|
"""
|
|
34
34
|
Recommend the right analytical approach for your problem.
|
|
35
35
|
|
|
@@ -42,6 +42,7 @@ def plan(question: str, data_description: str = None, goal: str = None, provider
|
|
|
42
42
|
If not specified, defaults to "anthropic" or infers from model.
|
|
43
43
|
model: Optional. The specific model to use. If not specified, uses the provider's default.
|
|
44
44
|
system_prompt: Optional. A custom system prompt to override the default planner persona.
|
|
45
|
+
max_tokens: Optional. Maximum tokens in the response. Defaults to 1024.
|
|
45
46
|
|
|
46
47
|
Returns:
|
|
47
48
|
A structured analytical plan covering the recommended approach, assumptions,
|
|
@@ -66,5 +67,5 @@ def plan(question: str, data_description: str = None, goal: str = None, provider
|
|
|
66
67
|
system_prompt=system_prompt or SYSTEM_PROMPT,
|
|
67
68
|
user_message=user_message,
|
|
68
69
|
model=model,
|
|
69
|
-
max_tokens=
|
|
70
|
+
max_tokens=max_tokens
|
|
70
71
|
)
|
|
@@ -39,7 +39,7 @@ HARDEST QUESTION TO ANSWER
|
|
|
39
39
|
"""
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
def redteam(text: str, stakeholder: str = None, provider: str = None, model: str = None, system_prompt: str = None) -> str:
|
|
42
|
+
def redteam(text: str, stakeholder: str = None, provider: str = None, model: str = None, system_prompt: str = None, max_tokens: int = 1024) -> str:
|
|
43
43
|
"""
|
|
44
44
|
Red-team a data science analysis writeup from the perspective of a skeptical stakeholder.
|
|
45
45
|
|
|
@@ -53,6 +53,7 @@ def redteam(text: str, stakeholder: str = None, provider: str = None, model: str
|
|
|
53
53
|
model: Optional. The specific model to use. If not specified, uses the provider's default.
|
|
54
54
|
system_prompt: Optional. A custom system prompt to fully override the default red team persona.
|
|
55
55
|
When provided, the stakeholder parameter is ignored.
|
|
56
|
+
max_tokens: Optional. Maximum tokens in the response. Defaults to 1024.
|
|
56
57
|
|
|
57
58
|
Returns:
|
|
58
59
|
The 3-5 hardest critiques the stakeholder would make, plus the single
|
|
@@ -81,5 +82,5 @@ def redteam(text: str, stakeholder: str = None, provider: str = None, model: str
|
|
|
81
82
|
system_prompt=system_prompt,
|
|
82
83
|
user_message=user_message,
|
|
83
84
|
model=model,
|
|
84
|
-
max_tokens=
|
|
85
|
+
max_tokens=max_tokens
|
|
85
86
|
)
|
|
@@ -42,7 +42,7 @@ BOTTOM LINE
|
|
|
42
42
|
[one sentence]
|
|
43
43
|
"""
|
|
44
44
|
|
|
45
|
-
def evaluate(text: str, provider: str = None, model: str = None, system_prompt: str = None) -> str:
|
|
45
|
+
def evaluate(text: str, provider: str = None, model: str = None, system_prompt: str = None, max_tokens: int = 1024) -> str:
|
|
46
46
|
"""
|
|
47
47
|
Evaluate a data science analysis writeup and return structured feedback.
|
|
48
48
|
|
|
@@ -52,6 +52,7 @@ def evaluate(text: str, provider: str = None, model: str = None, system_prompt:
|
|
|
52
52
|
If not specified, defaults to "anthropic" or infers from model.
|
|
53
53
|
model: Optional. The specific model to use. If not specified, uses the provider's default.
|
|
54
54
|
system_prompt: Optional. A custom system prompt to override the default reviewer persona.
|
|
55
|
+
max_tokens: Optional. Maximum tokens in the response. Defaults to 1024.
|
|
55
56
|
|
|
56
57
|
Returns:
|
|
57
58
|
Structured feedback across four dimensions.
|
|
@@ -71,5 +72,5 @@ def evaluate(text: str, provider: str = None, model: str = None, system_prompt:
|
|
|
71
72
|
system_prompt=system_prompt or SYSTEM_PROMPT,
|
|
72
73
|
user_message=user_message,
|
|
73
74
|
model=model,
|
|
74
|
-
max_tokens=
|
|
75
|
+
max_tokens=max_tokens
|
|
75
76
|
)
|
|
@@ -56,7 +56,7 @@ DEFAULT_SYSTEM_PROMPT = (
|
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
def ask(question: str, source: str = None, text: str = None, provider: str = None, model: str = None, system_prompt: str = None) -> str:
|
|
59
|
+
def ask(question: str, source: str = None, text: str = None, provider: str = None, model: str = None, system_prompt: str = None, max_tokens: int = 1024) -> str:
|
|
60
60
|
"""
|
|
61
61
|
Ask a question across a collection of analysis documents or raw text.
|
|
62
62
|
|
|
@@ -68,6 +68,7 @@ def ask(question: str, source: str = None, text: str = None, provider: str = Non
|
|
|
68
68
|
If not specified, defaults to "anthropic" or infers from model.
|
|
69
69
|
model: Optional. The specific model to use. If not specified, uses the provider's default.
|
|
70
70
|
system_prompt: Optional. A custom system prompt to override the default answering persona.
|
|
71
|
+
max_tokens: Optional. Maximum tokens in the response. Defaults to 1024.
|
|
71
72
|
|
|
72
73
|
Returns:
|
|
73
74
|
An answer grounded in the provided documents.
|
|
@@ -121,5 +122,5 @@ def ask(question: str, source: str = None, text: str = None, provider: str = Non
|
|
|
121
122
|
system_prompt=system_prompt or DEFAULT_SYSTEM_PROMPT,
|
|
122
123
|
user_message=user_message,
|
|
123
124
|
model=model,
|
|
124
|
-
max_tokens=
|
|
125
|
+
max_tokens=max_tokens
|
|
125
126
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bridgekit
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.9
|
|
4
4
|
Summary: AI tools that make you a better data scientist, not a redundant one.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://usebridgekit.com
|
|
@@ -136,6 +136,9 @@ onboarding users to reporting as a growth lever.
|
|
|
136
136
|
"""
|
|
137
137
|
|
|
138
138
|
print(evaluate(text))
|
|
139
|
+
|
|
140
|
+
# Override for longer analyses
|
|
141
|
+
print(evaluate(text, max_tokens=2048))
|
|
139
142
|
```
|
|
140
143
|
|
|
141
144
|
**Output:**
|
|
@@ -190,6 +193,9 @@ Supports `.txt`, `.md`, `.pdf`, `.docx`, `.pptx`, and `.ipynb` files.
|
|
|
190
193
|
from bridgekit import ask
|
|
191
194
|
|
|
192
195
|
print(ask("what drove churn in Q3?", source="reports/"))
|
|
196
|
+
|
|
197
|
+
# Override for longer responses
|
|
198
|
+
print(ask("what drove churn in Q3?", source="reports/", max_tokens=2048))
|
|
193
199
|
```
|
|
194
200
|
|
|
195
201
|
**From raw text:**
|
|
@@ -234,7 +240,7 @@ print(plan(
|
|
|
234
240
|
))
|
|
235
241
|
```
|
|
236
242
|
|
|
237
|
-
`data_description` and `
|
|
243
|
+
`data_description`, `goal`, and `max_tokens` are optional — the more context you provide, the more tailored the recommendation.
|
|
238
244
|
|
|
239
245
|
**`goal` examples:** `"causal inference"`, `"prediction"`, `"segmentation"`, `"hypothesis testing"`, `"exploration"`
|
|
240
246
|
|
|
@@ -297,6 +303,9 @@ print(redteam(text))
|
|
|
297
303
|
# Or specify a stakeholder
|
|
298
304
|
print(redteam(text, stakeholder="VP of Engineering"))
|
|
299
305
|
print(redteam(text, stakeholder="VP of Marketing"))
|
|
306
|
+
|
|
307
|
+
# Override for longer responses
|
|
308
|
+
print(redteam(text, max_tokens=2048))
|
|
300
309
|
```
|
|
301
310
|
|
|
302
311
|
Same writeup, different attack angles:
|
|
@@ -387,6 +396,72 @@ willing to commit to — and what's your confidence interval on that estimate?"
|
|
|
387
396
|
|
|
388
397
|
---
|
|
389
398
|
|
|
399
|
+
## Tool #5: Compare
|
|
400
|
+
|
|
401
|
+
Run the same tool through two providers and see both outputs side by side. Useful for evaluating which model works best for your use case — as a one-liner.
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
from bridgekit import compare
|
|
405
|
+
|
|
406
|
+
text = """
|
|
407
|
+
I analyzed 90 days of user behavior data to understand what drives subscription
|
|
408
|
+
upgrades. Users who engaged with the reporting feature within their first week
|
|
409
|
+
were 3x more likely to upgrade within 30 days. I recommend we prioritize
|
|
410
|
+
onboarding users to reporting as a growth lever.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
# Compare evaluate across Anthropic and OpenAI (default)
|
|
414
|
+
print(compare(text, tool="evaluate"))
|
|
415
|
+
|
|
416
|
+
# Compare plan across two providers
|
|
417
|
+
print(compare("Did our onboarding flow reduce churn?", tool="plan"))
|
|
418
|
+
|
|
419
|
+
# Compare redteam with a specific stakeholder
|
|
420
|
+
print(compare(text, tool="redteam", stakeholder="VP of Finance"))
|
|
421
|
+
|
|
422
|
+
# Override models for each provider
|
|
423
|
+
print(compare(text, model_a="claude-haiku-4-5-20251001", model_b="gpt-4-turbo"))
|
|
424
|
+
|
|
425
|
+
# Compare two specific providers
|
|
426
|
+
print(compare(text, providers=["anthropic", "gemini"]))
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
**Parameters:**
|
|
430
|
+
- `tool` - which tool to run: `"evaluate"`, `"plan"`, or `"redteam"` (defaults to `"evaluate"`)
|
|
431
|
+
- `providers` - list of exactly two providers to compare (defaults to `["anthropic", "openai"]`)
|
|
432
|
+
- `model_a`, `model_b` - optional model overrides for the first and second provider
|
|
433
|
+
- `**kwargs` - additional arguments passed through to the underlying tool (e.g. `max_tokens`, `stakeholder`, `data_description`)
|
|
434
|
+
|
|
435
|
+
**Output:**
|
|
436
|
+
```
|
|
437
|
+
BRIDGEKIT COMPARE: EVALUATE
|
|
438
|
+
─────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
ANTHROPIC claude-opus-4-8
|
|
441
|
+
─────────────────────────────────────────
|
|
442
|
+
BRIDGEKIT ANALYSIS REVIEW
|
|
443
|
+
─────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
1. CLARITY
|
|
446
|
+
✅ STRONG — Clean and jargon-free.
|
|
447
|
+
|
|
448
|
+
...
|
|
449
|
+
|
|
450
|
+
OPENAI gpt-4o
|
|
451
|
+
─────────────────────────────────────────
|
|
452
|
+
BRIDGEKIT ANALYSIS REVIEW
|
|
453
|
+
─────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
1. CLARITY
|
|
456
|
+
⚠️ NEEDS WORK — The phrase "engagement feature" needs more context.
|
|
457
|
+
|
|
458
|
+
...
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Both providers are called in parallel, so the total wait time is the slower of the two — not the sum.
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
390
465
|
## Multi-Provider Support
|
|
391
466
|
|
|
392
467
|
Bridgekit now supports multiple AI providers so you're not locked into one API. You can use Anthropic, OpenAI, or Google Gemini models with any tool.
|
|
@@ -414,7 +489,7 @@ Bridgekit automatically detects the provider from model names:
|
|
|
414
489
|
- Models starting with "gemini" → Google Gemini
|
|
415
490
|
|
|
416
491
|
**Default models by provider:**
|
|
417
|
-
- Anthropic: `claude-
|
|
492
|
+
- Anthropic: `claude-opus-4-8`
|
|
418
493
|
- OpenAI: `gpt-4o`
|
|
419
494
|
- Gemini: `gemini-1.5-pro`
|
|
420
495
|
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
bridgekit/__init__.py
|
|
5
5
|
bridgekit/cli.py
|
|
6
|
+
bridgekit/compare.py
|
|
6
7
|
bridgekit/config.py
|
|
7
8
|
bridgekit/planner.py
|
|
8
9
|
bridgekit/providers.py
|
|
@@ -15,6 +16,8 @@ bridgekit.egg-info/dependency_links.txt
|
|
|
15
16
|
bridgekit.egg-info/entry_points.txt
|
|
16
17
|
bridgekit.egg-info/requires.txt
|
|
17
18
|
bridgekit.egg-info/top_level.txt
|
|
19
|
+
tests/test_cli.py
|
|
20
|
+
tests/test_compare.py
|
|
18
21
|
tests/test_config.py
|
|
19
22
|
tests/test_planner.py
|
|
20
23
|
tests/test_providers.py
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import pytest
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
FAKE_PLAN = "BRIDGEKIT ANALYSIS PLAN\n─────\nRECOMMENDED APPROACH\nUse a t-test."
|
|
7
|
+
FAKE_REVIEW = "BRIDGEKIT ANALYSIS REVIEW\n─────\n1. CLARITY\n✅ STRONG Clear writing."
|
|
8
|
+
FAKE_REDTEAM = "BRIDGEKIT RED TEAM\n─────\nCRITIQUE 1: Sample Size\nHARDEST QUESTION TO ANSWER\nWhat is n?"
|
|
9
|
+
FAKE_SEARCH = "Based on the documents, the answer is 42."
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestPlanCommand:
|
|
13
|
+
def test_basic_question(self, capsys):
|
|
14
|
+
with patch("bridgekit.cli.plan", return_value=FAKE_PLAN) as mock_plan:
|
|
15
|
+
with patch("sys.argv", ["bridgekit", "plan", "should I use a t-test?"]):
|
|
16
|
+
from bridgekit.cli import main
|
|
17
|
+
main()
|
|
18
|
+
mock_plan.assert_called_once_with(
|
|
19
|
+
question="should I use a t-test?",
|
|
20
|
+
data_description=None,
|
|
21
|
+
goal=None,
|
|
22
|
+
provider=None,
|
|
23
|
+
model=None,
|
|
24
|
+
)
|
|
25
|
+
assert FAKE_PLAN in capsys.readouterr().out
|
|
26
|
+
|
|
27
|
+
def test_with_data_and_goal(self, capsys):
|
|
28
|
+
with patch("bridgekit.cli.plan", return_value=FAKE_PLAN) as mock_plan:
|
|
29
|
+
with patch("sys.argv", ["bridgekit", "plan", "my question",
|
|
30
|
+
"--data", "50 rows", "--goal", "compare means"]):
|
|
31
|
+
from bridgekit.cli import main
|
|
32
|
+
main()
|
|
33
|
+
mock_plan.assert_called_once_with(
|
|
34
|
+
question="my question",
|
|
35
|
+
data_description="50 rows",
|
|
36
|
+
goal="compare means",
|
|
37
|
+
provider=None,
|
|
38
|
+
model=None,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def test_with_provider_and_model(self):
|
|
42
|
+
with patch("bridgekit.cli.plan", return_value=FAKE_PLAN) as mock_plan:
|
|
43
|
+
with patch("sys.argv", ["bridgekit", "plan", "my question",
|
|
44
|
+
"--provider", "openai", "--model", "gpt-4o"]):
|
|
45
|
+
from bridgekit.cli import main
|
|
46
|
+
main()
|
|
47
|
+
mock_plan.assert_called_once_with(
|
|
48
|
+
question="my question",
|
|
49
|
+
data_description=None,
|
|
50
|
+
goal=None,
|
|
51
|
+
provider="openai",
|
|
52
|
+
model="gpt-4o",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def test_missing_question_exits(self):
|
|
56
|
+
with patch("sys.argv", ["bridgekit", "plan"]):
|
|
57
|
+
from bridgekit.cli import main
|
|
58
|
+
with pytest.raises(SystemExit):
|
|
59
|
+
main()
|
|
60
|
+
|
|
61
|
+
def test_environment_error_exits(self, capsys):
|
|
62
|
+
with patch("bridgekit.cli.plan", side_effect=EnvironmentError("ANTHROPIC_API_KEY not found")):
|
|
63
|
+
with patch("sys.argv", ["bridgekit", "plan", "my question"]):
|
|
64
|
+
from bridgekit.cli import main
|
|
65
|
+
with pytest.raises(SystemExit) as exc:
|
|
66
|
+
main()
|
|
67
|
+
assert exc.value.code == 1
|
|
68
|
+
assert "ANTHROPIC_API_KEY" in capsys.readouterr().err
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestReviewCommand:
|
|
72
|
+
def test_basic_text(self, capsys):
|
|
73
|
+
with patch("bridgekit.cli.evaluate", return_value=FAKE_REVIEW) as mock_evaluate:
|
|
74
|
+
with patch("sys.argv", ["bridgekit", "review", "my analysis text"]):
|
|
75
|
+
from bridgekit.cli import main
|
|
76
|
+
main()
|
|
77
|
+
mock_evaluate.assert_called_once_with(
|
|
78
|
+
text="my analysis text",
|
|
79
|
+
provider=None,
|
|
80
|
+
model=None,
|
|
81
|
+
)
|
|
82
|
+
assert FAKE_REVIEW in capsys.readouterr().out
|
|
83
|
+
|
|
84
|
+
def test_missing_text_exits(self):
|
|
85
|
+
with patch("sys.argv", ["bridgekit", "review"]):
|
|
86
|
+
from bridgekit.cli import main
|
|
87
|
+
with pytest.raises(SystemExit):
|
|
88
|
+
main()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestRedteamCommand:
|
|
92
|
+
def test_basic_text(self, capsys):
|
|
93
|
+
with patch("bridgekit.cli.redteam", return_value=FAKE_REDTEAM) as mock_redteam:
|
|
94
|
+
with patch("sys.argv", ["bridgekit", "redteam", "my analysis text"]):
|
|
95
|
+
from bridgekit.cli import main
|
|
96
|
+
main()
|
|
97
|
+
mock_redteam.assert_called_once_with(
|
|
98
|
+
text="my analysis text",
|
|
99
|
+
stakeholder=None,
|
|
100
|
+
provider=None,
|
|
101
|
+
model=None,
|
|
102
|
+
)
|
|
103
|
+
assert FAKE_REDTEAM in capsys.readouterr().out
|
|
104
|
+
|
|
105
|
+
def test_with_stakeholder(self):
|
|
106
|
+
with patch("bridgekit.cli.redteam", return_value=FAKE_REDTEAM) as mock_redteam:
|
|
107
|
+
with patch("sys.argv", ["bridgekit", "redteam", "my analysis text",
|
|
108
|
+
"--stakeholder", "VP of Finance"]):
|
|
109
|
+
from bridgekit.cli import main
|
|
110
|
+
main()
|
|
111
|
+
mock_redteam.assert_called_once_with(
|
|
112
|
+
text="my analysis text",
|
|
113
|
+
stakeholder="VP of Finance",
|
|
114
|
+
provider=None,
|
|
115
|
+
model=None,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def test_missing_text_exits(self):
|
|
119
|
+
with patch("sys.argv", ["bridgekit", "redteam"]):
|
|
120
|
+
from bridgekit.cli import main
|
|
121
|
+
with pytest.raises(SystemExit):
|
|
122
|
+
main()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestSearchCommand:
|
|
126
|
+
def test_with_source(self, capsys):
|
|
127
|
+
with patch("bridgekit.cli.ask", return_value=FAKE_SEARCH) as mock_ask:
|
|
128
|
+
with patch("sys.argv", ["bridgekit", "search", "my question",
|
|
129
|
+
"--source", "./my_docs"]):
|
|
130
|
+
from bridgekit.cli import main
|
|
131
|
+
main()
|
|
132
|
+
mock_ask.assert_called_once_with(
|
|
133
|
+
question="my question",
|
|
134
|
+
source="./my_docs",
|
|
135
|
+
text=None,
|
|
136
|
+
provider=None,
|
|
137
|
+
model=None,
|
|
138
|
+
)
|
|
139
|
+
assert FAKE_SEARCH in capsys.readouterr().out
|
|
140
|
+
|
|
141
|
+
def test_with_text(self):
|
|
142
|
+
with patch("bridgekit.cli.ask", return_value=FAKE_SEARCH) as mock_ask:
|
|
143
|
+
with patch("sys.argv", ["bridgekit", "search", "my question",
|
|
144
|
+
"--text", "some raw text"]):
|
|
145
|
+
from bridgekit.cli import main
|
|
146
|
+
main()
|
|
147
|
+
mock_ask.assert_called_once_with(
|
|
148
|
+
question="my question",
|
|
149
|
+
source=None,
|
|
150
|
+
text="some raw text",
|
|
151
|
+
provider=None,
|
|
152
|
+
model=None,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def test_missing_source_and_text_exits(self, capsys):
|
|
156
|
+
with patch("sys.argv", ["bridgekit", "search", "my question"]):
|
|
157
|
+
from bridgekit.cli import main
|
|
158
|
+
with pytest.raises(SystemExit) as exc:
|
|
159
|
+
main()
|
|
160
|
+
assert exc.value.code == 1
|
|
161
|
+
assert "error" in capsys.readouterr().err
|
|
162
|
+
|
|
163
|
+
def test_missing_question_exits(self):
|
|
164
|
+
with patch("sys.argv", ["bridgekit", "search"]):
|
|
165
|
+
from bridgekit.cli import main
|
|
166
|
+
with pytest.raises(SystemExit):
|
|
167
|
+
main()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestNoCommand:
|
|
171
|
+
def test_no_subcommand_exits(self):
|
|
172
|
+
with patch("sys.argv", ["bridgekit"]):
|
|
173
|
+
from bridgekit.cli import main
|
|
174
|
+
with pytest.raises(SystemExit):
|
|
175
|
+
main()
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pytest
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
# Helpers
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
FAKE_ANTHROPIC = (
|
|
11
|
+
"BRIDGEKIT ANALYSIS REVIEW\n"
|
|
12
|
+
"─────────────────────────────────────────\n\n"
|
|
13
|
+
"1. CLARITY\n"
|
|
14
|
+
"✅ STRONG — Clear and jargon-free.\n\n"
|
|
15
|
+
"─────────────────────────────────────────\n"
|
|
16
|
+
"BOTTOM LINE\n"
|
|
17
|
+
"Add quantified business impact."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
FAKE_OPENAI = (
|
|
21
|
+
"BRIDGEKIT ANALYSIS REVIEW\n"
|
|
22
|
+
"─────────────────────────────────────────\n\n"
|
|
23
|
+
"1. CLARITY\n"
|
|
24
|
+
"⚠️ NEEDS WORK — Too much jargon.\n\n"
|
|
25
|
+
"─────────────────────────────────────────\n"
|
|
26
|
+
"BOTTOM LINE\n"
|
|
27
|
+
"Define your methodology more clearly."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
FAKE_SUMMARY = "Both agreed on Clarity. Anthropic was harsher on Statistical Rigor."
|
|
31
|
+
|
|
32
|
+
DEFAULT_RESPONSES = {
|
|
33
|
+
"anthropic": FAKE_ANTHROPIC,
|
|
34
|
+
"openai": FAKE_OPENAI,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _make_call_tool_side_effect(responses: dict):
|
|
39
|
+
def side_effect(tool_name, text, provider, model, kwargs):
|
|
40
|
+
return responses.get(provider, "fallback output")
|
|
41
|
+
return side_effect
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _patches(responses=None):
|
|
45
|
+
"""Context manager stacking _call_tool and _synthesize patches."""
|
|
46
|
+
if responses is None:
|
|
47
|
+
responses = DEFAULT_RESPONSES
|
|
48
|
+
|
|
49
|
+
class _Ctx:
|
|
50
|
+
def __enter__(self):
|
|
51
|
+
self._p1 = patch("bridgekit.compare._call_tool", side_effect=_make_call_tool_side_effect(responses))
|
|
52
|
+
self._p2 = patch("bridgekit.compare._synthesize", return_value=FAKE_SUMMARY)
|
|
53
|
+
self._p1.__enter__()
|
|
54
|
+
self._p2.__enter__()
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *args):
|
|
58
|
+
self._p2.__exit__(*args)
|
|
59
|
+
self._p1.__exit__(*args)
|
|
60
|
+
|
|
61
|
+
return _Ctx()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Tests
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
class TestCompareReturnsString:
|
|
69
|
+
def test_returns_string(self):
|
|
70
|
+
with _patches():
|
|
71
|
+
from bridgekit.compare import compare
|
|
72
|
+
result = compare("Some analysis.")
|
|
73
|
+
assert isinstance(result, str)
|
|
74
|
+
|
|
75
|
+
def test_returns_non_empty_string(self):
|
|
76
|
+
with _patches():
|
|
77
|
+
from bridgekit.compare import compare
|
|
78
|
+
result = compare("Some analysis.")
|
|
79
|
+
assert len(result) > 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestCompareOutputStructure:
|
|
83
|
+
def test_output_contains_tool_header(self):
|
|
84
|
+
with _patches():
|
|
85
|
+
from bridgekit.compare import compare
|
|
86
|
+
result = compare("Some analysis.")
|
|
87
|
+
assert "BRIDGEKIT COMPARE: EVALUATE" in result
|
|
88
|
+
|
|
89
|
+
def test_output_contains_summary_section(self):
|
|
90
|
+
with _patches():
|
|
91
|
+
from bridgekit.compare import compare
|
|
92
|
+
result = compare("Some analysis.")
|
|
93
|
+
assert "SUMMARY" in result
|
|
94
|
+
assert FAKE_SUMMARY in result
|
|
95
|
+
|
|
96
|
+
def test_summary_appears_before_provider_outputs(self):
|
|
97
|
+
with _patches():
|
|
98
|
+
from bridgekit.compare import compare
|
|
99
|
+
result = compare("Some analysis.")
|
|
100
|
+
assert result.index("SUMMARY") < result.index(FAKE_ANTHROPIC)
|
|
101
|
+
assert result.index("SUMMARY") < result.index(FAKE_OPENAI)
|
|
102
|
+
|
|
103
|
+
def test_output_contains_both_provider_labels(self):
|
|
104
|
+
with _patches():
|
|
105
|
+
from bridgekit.compare import compare
|
|
106
|
+
result = compare("Some analysis.")
|
|
107
|
+
assert "ANTHROPIC" in result
|
|
108
|
+
assert "OPENAI" in result
|
|
109
|
+
|
|
110
|
+
def test_output_contains_both_responses(self):
|
|
111
|
+
with _patches():
|
|
112
|
+
from bridgekit.compare import compare
|
|
113
|
+
result = compare("Some analysis.")
|
|
114
|
+
assert FAKE_ANTHROPIC in result
|
|
115
|
+
assert FAKE_OPENAI in result
|
|
116
|
+
|
|
117
|
+
def test_anthropic_appears_before_openai(self):
|
|
118
|
+
with _patches():
|
|
119
|
+
from bridgekit.compare import compare
|
|
120
|
+
result = compare("Some analysis.")
|
|
121
|
+
assert result.index(FAKE_ANTHROPIC) < result.index(FAKE_OPENAI)
|
|
122
|
+
|
|
123
|
+
def test_plan_tool_header(self):
|
|
124
|
+
with _patches():
|
|
125
|
+
from bridgekit.compare import compare
|
|
126
|
+
result = compare("What caused churn?", tool="plan")
|
|
127
|
+
assert "BRIDGEKIT COMPARE: PLAN" in result
|
|
128
|
+
|
|
129
|
+
def test_redteam_tool_header(self):
|
|
130
|
+
with _patches():
|
|
131
|
+
from bridgekit.compare import compare
|
|
132
|
+
result = compare("Some analysis.", tool="redteam")
|
|
133
|
+
assert "BRIDGEKIT COMPARE: REDTEAM" in result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestCompareValidation:
|
|
137
|
+
def test_empty_text_raises_value_error(self):
|
|
138
|
+
from bridgekit.compare import compare
|
|
139
|
+
with pytest.raises(ValueError, match="empty"):
|
|
140
|
+
compare("")
|
|
141
|
+
|
|
142
|
+
def test_whitespace_text_raises_value_error(self):
|
|
143
|
+
from bridgekit.compare import compare
|
|
144
|
+
with pytest.raises(ValueError, match="empty"):
|
|
145
|
+
compare(" ")
|
|
146
|
+
|
|
147
|
+
def test_invalid_tool_raises_value_error(self):
|
|
148
|
+
from bridgekit.compare import compare
|
|
149
|
+
with pytest.raises(ValueError, match="Unknown tool"):
|
|
150
|
+
compare("Some analysis.", tool="summarize")
|
|
151
|
+
|
|
152
|
+
def test_too_few_providers_raises_value_error(self):
|
|
153
|
+
from bridgekit.compare import compare
|
|
154
|
+
with pytest.raises(ValueError, match="exactly 2"):
|
|
155
|
+
compare("Some analysis.", providers=["anthropic"])
|
|
156
|
+
|
|
157
|
+
def test_too_many_providers_raises_value_error(self):
|
|
158
|
+
from bridgekit.compare import compare
|
|
159
|
+
with pytest.raises(ValueError, match="exactly 2"):
|
|
160
|
+
compare("Some analysis.", providers=["anthropic", "openai", "gemini"])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TestCompareModelOverrides:
|
|
164
|
+
def test_model_a_appears_in_output(self):
|
|
165
|
+
with _patches():
|
|
166
|
+
from bridgekit.compare import compare
|
|
167
|
+
result = compare("Some analysis.", model_a="claude-haiku-4-5-20251001")
|
|
168
|
+
assert "claude-haiku-4-5-20251001" in result
|
|
169
|
+
|
|
170
|
+
def test_model_b_appears_in_output(self):
|
|
171
|
+
with _patches():
|
|
172
|
+
from bridgekit.compare import compare
|
|
173
|
+
result = compare("Some analysis.", model_b="gpt-4-turbo")
|
|
174
|
+
assert "gpt-4-turbo" in result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TestCompareDefaultProviders:
|
|
178
|
+
def test_defaults_to_anthropic_and_openai(self):
|
|
179
|
+
with _patches():
|
|
180
|
+
from bridgekit.compare import compare
|
|
181
|
+
result = compare("Some analysis.")
|
|
182
|
+
assert "ANTHROPIC" in result
|
|
183
|
+
assert "OPENAI" in result
|
|
184
|
+
|
|
185
|
+
def test_default_models_shown(self):
|
|
186
|
+
with _patches():
|
|
187
|
+
from bridgekit.compare import compare
|
|
188
|
+
result = compare("Some analysis.")
|
|
189
|
+
assert "claude-opus-4-8" in result
|
|
190
|
+
assert "gpt-4o" in result
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TestCompareSynthesis:
|
|
194
|
+
def test_synthesize_called_once(self):
|
|
195
|
+
with patch("bridgekit.compare._call_tool", side_effect=_make_call_tool_side_effect(DEFAULT_RESPONSES)):
|
|
196
|
+
with patch("bridgekit.compare._synthesize", return_value=FAKE_SUMMARY) as mock_syn:
|
|
197
|
+
from bridgekit.compare import compare
|
|
198
|
+
compare("Some analysis.")
|
|
199
|
+
mock_syn.assert_called_once()
|
|
200
|
+
|
|
201
|
+
def test_synthesize_receives_both_outputs(self):
|
|
202
|
+
captured = []
|
|
203
|
+
|
|
204
|
+
def capture_synth(results):
|
|
205
|
+
captured.extend(results)
|
|
206
|
+
return FAKE_SUMMARY
|
|
207
|
+
|
|
208
|
+
with patch("bridgekit.compare._call_tool", side_effect=_make_call_tool_side_effect(DEFAULT_RESPONSES)):
|
|
209
|
+
with patch("bridgekit.compare._synthesize", side_effect=capture_synth):
|
|
210
|
+
from bridgekit.compare import compare
|
|
211
|
+
compare("Some analysis.")
|
|
212
|
+
|
|
213
|
+
providers_seen = {r[0] for r in captured}
|
|
214
|
+
assert "anthropic" in providers_seen
|
|
215
|
+
assert "openai" in providers_seen
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class TestCompareApiCallShape:
|
|
219
|
+
def test_both_providers_called(self):
|
|
220
|
+
calls = []
|
|
221
|
+
|
|
222
|
+
def capture(tool_name, text, provider, model, kwargs):
|
|
223
|
+
calls.append(provider)
|
|
224
|
+
return "output"
|
|
225
|
+
|
|
226
|
+
with patch("bridgekit.compare._call_tool", side_effect=capture):
|
|
227
|
+
with patch("bridgekit.compare._synthesize", return_value=FAKE_SUMMARY):
|
|
228
|
+
from bridgekit.compare import compare
|
|
229
|
+
compare("Some analysis.")
|
|
230
|
+
|
|
231
|
+
assert sorted(calls) == ["anthropic", "openai"]
|
|
232
|
+
|
|
233
|
+
def test_user_text_passed_to_both_calls(self):
|
|
234
|
+
user_text = "Our conversion rate improved after the campaign."
|
|
235
|
+
seen_texts = []
|
|
236
|
+
|
|
237
|
+
def capture(tool_name, text, provider, model, kwargs):
|
|
238
|
+
seen_texts.append(text)
|
|
239
|
+
return "output"
|
|
240
|
+
|
|
241
|
+
with patch("bridgekit.compare._call_tool", side_effect=capture):
|
|
242
|
+
with patch("bridgekit.compare._synthesize", return_value=FAKE_SUMMARY):
|
|
243
|
+
from bridgekit.compare import compare
|
|
244
|
+
compare(user_text)
|
|
245
|
+
|
|
246
|
+
assert len(seen_texts) == 2
|
|
247
|
+
assert all(t == user_text for t in seen_texts)
|
|
248
|
+
|
|
249
|
+
def test_tool_name_passed_to_both_calls(self):
|
|
250
|
+
tool_names = []
|
|
251
|
+
|
|
252
|
+
def capture(tool_name, text, provider, model, kwargs):
|
|
253
|
+
tool_names.append(tool_name)
|
|
254
|
+
return "output"
|
|
255
|
+
|
|
256
|
+
with patch("bridgekit.compare._call_tool", side_effect=capture):
|
|
257
|
+
with patch("bridgekit.compare._synthesize", return_value=FAKE_SUMMARY):
|
|
258
|
+
from bridgekit.compare import compare
|
|
259
|
+
compare("Some analysis.", tool="redteam")
|
|
260
|
+
|
|
261
|
+
assert all(n == "redteam" for n in tool_names)
|
|
262
|
+
|
|
263
|
+
def test_kwargs_forwarded_to_tool(self):
|
|
264
|
+
received_kwargs = []
|
|
265
|
+
|
|
266
|
+
def capture(tool_name, text, provider, model, kwargs):
|
|
267
|
+
received_kwargs.append(kwargs)
|
|
268
|
+
return "output"
|
|
269
|
+
|
|
270
|
+
with patch("bridgekit.compare._call_tool", side_effect=capture):
|
|
271
|
+
with patch("bridgekit.compare._synthesize", return_value=FAKE_SUMMARY):
|
|
272
|
+
from bridgekit.compare import compare
|
|
273
|
+
compare("Some analysis.", max_tokens=2048)
|
|
274
|
+
|
|
275
|
+
assert all(kw.get("max_tokens") == 2048 for kw in received_kwargs)
|
|
@@ -192,3 +192,33 @@ class TestPlanOptionalParameters:
|
|
|
192
192
|
content = str(messages_arg)
|
|
193
193
|
assert "5,000 users split 50/50." in content
|
|
194
194
|
assert "causal inference" in content
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class TestPlanMaxTokens:
|
|
198
|
+
"""plan() should pass max_tokens through to the API."""
|
|
199
|
+
|
|
200
|
+
def test_default_max_tokens_is_1024(self):
|
|
201
|
+
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
|
|
202
|
+
with patch("anthropic.Anthropic") as MockAnthropic:
|
|
203
|
+
mock_client = MagicMock()
|
|
204
|
+
mock_client.messages.create.return_value = _make_mock_message(FAKE_RESPONSE)
|
|
205
|
+
MockAnthropic.return_value = mock_client
|
|
206
|
+
|
|
207
|
+
from bridgekit.planner import plan
|
|
208
|
+
plan("Does our new onboarding flow increase upgrade rates?")
|
|
209
|
+
|
|
210
|
+
call_kwargs = mock_client.messages.create.call_args
|
|
211
|
+
assert call_kwargs.kwargs.get("max_tokens") == 1024
|
|
212
|
+
|
|
213
|
+
def test_custom_max_tokens_reaches_api(self):
|
|
214
|
+
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
|
|
215
|
+
with patch("anthropic.Anthropic") as MockAnthropic:
|
|
216
|
+
mock_client = MagicMock()
|
|
217
|
+
mock_client.messages.create.return_value = _make_mock_message(FAKE_RESPONSE)
|
|
218
|
+
MockAnthropic.return_value = mock_client
|
|
219
|
+
|
|
220
|
+
from bridgekit.planner import plan
|
|
221
|
+
plan("Does our new onboarding flow increase upgrade rates?", max_tokens=2048)
|
|
222
|
+
|
|
223
|
+
call_kwargs = mock_client.messages.create.call_args
|
|
224
|
+
assert call_kwargs.kwargs.get("max_tokens") == 2048
|
|
@@ -153,3 +153,33 @@ class TestRedteamCustomSystemPrompt:
|
|
|
153
153
|
|
|
154
154
|
call_kwargs = mock_client.messages.create.call_args
|
|
155
155
|
assert call_kwargs.kwargs.get("system") == custom_prompt
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TestRedteamMaxTokens:
|
|
159
|
+
"""redteam() should pass max_tokens through to the API."""
|
|
160
|
+
|
|
161
|
+
def test_default_max_tokens_is_1024(self):
|
|
162
|
+
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
|
|
163
|
+
with patch("anthropic.Anthropic") as MockAnthropic:
|
|
164
|
+
mock_client = MagicMock()
|
|
165
|
+
mock_client.messages.create.return_value = _make_mock_message(FAKE_RESPONSE)
|
|
166
|
+
MockAnthropic.return_value = mock_client
|
|
167
|
+
|
|
168
|
+
from bridgekit.redteam import redteam
|
|
169
|
+
redteam("Some analysis text.")
|
|
170
|
+
|
|
171
|
+
call_kwargs = mock_client.messages.create.call_args
|
|
172
|
+
assert call_kwargs.kwargs.get("max_tokens") == 1024
|
|
173
|
+
|
|
174
|
+
def test_custom_max_tokens_reaches_api(self):
|
|
175
|
+
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
|
|
176
|
+
with patch("anthropic.Anthropic") as MockAnthropic:
|
|
177
|
+
mock_client = MagicMock()
|
|
178
|
+
mock_client.messages.create.return_value = _make_mock_message(FAKE_RESPONSE)
|
|
179
|
+
MockAnthropic.return_value = mock_client
|
|
180
|
+
|
|
181
|
+
from bridgekit.redteam import redteam
|
|
182
|
+
redteam("Some analysis text.", max_tokens=2048)
|
|
183
|
+
|
|
184
|
+
call_kwargs = mock_client.messages.create.call_args
|
|
185
|
+
assert call_kwargs.kwargs.get("max_tokens") == 2048
|
|
@@ -176,3 +176,33 @@ class TestEvaluateCustomSystemPrompt:
|
|
|
176
176
|
|
|
177
177
|
call_kwargs = mock_client.messages.create.call_args
|
|
178
178
|
assert call_kwargs.kwargs.get("system") == custom_prompt
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestEvaluateMaxTokens:
|
|
182
|
+
"""evaluate() should pass max_tokens through to the API."""
|
|
183
|
+
|
|
184
|
+
def test_default_max_tokens_is_1024(self):
|
|
185
|
+
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
|
|
186
|
+
with patch("anthropic.Anthropic") as MockAnthropic:
|
|
187
|
+
mock_client = MagicMock()
|
|
188
|
+
mock_client.messages.create.return_value = _make_mock_message(FAKE_RESPONSE)
|
|
189
|
+
MockAnthropic.return_value = mock_client
|
|
190
|
+
|
|
191
|
+
from bridgekit.reviewer import evaluate
|
|
192
|
+
evaluate("Some analysis text.")
|
|
193
|
+
|
|
194
|
+
call_kwargs = mock_client.messages.create.call_args
|
|
195
|
+
assert call_kwargs.kwargs.get("max_tokens") == 1024
|
|
196
|
+
|
|
197
|
+
def test_custom_max_tokens_reaches_api(self):
|
|
198
|
+
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
|
|
199
|
+
with patch("anthropic.Anthropic") as MockAnthropic:
|
|
200
|
+
mock_client = MagicMock()
|
|
201
|
+
mock_client.messages.create.return_value = _make_mock_message(FAKE_RESPONSE)
|
|
202
|
+
MockAnthropic.return_value = mock_client
|
|
203
|
+
|
|
204
|
+
from bridgekit.reviewer import evaluate
|
|
205
|
+
evaluate("Some analysis text.", max_tokens=2048)
|
|
206
|
+
|
|
207
|
+
call_kwargs = mock_client.messages.create.call_args
|
|
208
|
+
assert call_kwargs.kwargs.get("max_tokens") == 2048
|
|
@@ -257,3 +257,45 @@ class TestAskWithSourceFolder:
|
|
|
257
257
|
from bridgekit.search import ask
|
|
258
258
|
with pytest.raises(ValueError, match="No content found"):
|
|
259
259
|
ask("What happened?", source=tmpdir)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class TestAskMaxTokens:
|
|
263
|
+
"""ask() should pass max_tokens through to the API."""
|
|
264
|
+
|
|
265
|
+
def test_default_max_tokens_is_1024(self):
|
|
266
|
+
mock_chromadb, mock_ef = _make_mock_chromadb()
|
|
267
|
+
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
|
|
268
|
+
with patch("anthropic.Anthropic") as MockAnthropic, \
|
|
269
|
+
patch("chromadb.Client", mock_chromadb.Client), \
|
|
270
|
+
patch(
|
|
271
|
+
"chromadb.utils.embedding_functions.SentenceTransformerEmbeddingFunction",
|
|
272
|
+
mock_ef,
|
|
273
|
+
):
|
|
274
|
+
mock_client = MagicMock()
|
|
275
|
+
mock_client.messages.create.return_value = _make_mock_message(FAKE_ANSWER)
|
|
276
|
+
MockAnthropic.return_value = mock_client
|
|
277
|
+
|
|
278
|
+
from bridgekit.search import ask
|
|
279
|
+
ask("What was the conversion rate?", text="The conversion rate increased by 12%.")
|
|
280
|
+
|
|
281
|
+
call_kwargs = mock_client.messages.create.call_args
|
|
282
|
+
assert call_kwargs.kwargs.get("max_tokens") == 1024
|
|
283
|
+
|
|
284
|
+
def test_custom_max_tokens_reaches_api(self):
|
|
285
|
+
mock_chromadb, mock_ef = _make_mock_chromadb()
|
|
286
|
+
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
|
|
287
|
+
with patch("anthropic.Anthropic") as MockAnthropic, \
|
|
288
|
+
patch("chromadb.Client", mock_chromadb.Client), \
|
|
289
|
+
patch(
|
|
290
|
+
"chromadb.utils.embedding_functions.SentenceTransformerEmbeddingFunction",
|
|
291
|
+
mock_ef,
|
|
292
|
+
):
|
|
293
|
+
mock_client = MagicMock()
|
|
294
|
+
mock_client.messages.create.return_value = _make_mock_message(FAKE_ANSWER)
|
|
295
|
+
MockAnthropic.return_value = mock_client
|
|
296
|
+
|
|
297
|
+
from bridgekit.search import ask
|
|
298
|
+
ask("What was the conversion rate?", text="The conversion rate increased by 12%.", max_tokens=2048)
|
|
299
|
+
|
|
300
|
+
call_kwargs = mock_client.messages.create.call_args
|
|
301
|
+
assert call_kwargs.kwargs.get("max_tokens") == 2048
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|