dissenter 1.0.2__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.
- dissent/__init__.py +1 -0
- dissent/cli.py +274 -0
- dissent/config.py +96 -0
- dissent/detect.py +65 -0
- dissent/roles/analyst.toml +3 -0
- dissent/roles/chairman.toml +3 -0
- dissent/roles/conservative.toml +3 -0
- dissent/roles/contrarian.toml +3 -0
- dissent/roles/devils_advocate.toml +3 -0
- dissent/roles/liberal.toml +3 -0
- dissent/roles/pragmatist.toml +3 -0
- dissent/roles/researcher.toml +3 -0
- dissent/roles/second_opinion.toml +3 -0
- dissent/roles/skeptic.toml +3 -0
- dissent/roles.py +29 -0
- dissent/runner.py +318 -0
- dissent/synthesis.py +232 -0
- dissent/wizard.py +143 -0
- dissenter-1.0.2.dist-info/METADATA +13 -0
- dissenter-1.0.2.dist-info/RECORD +22 -0
- dissenter-1.0.2.dist-info/WHEEL +4 -0
- dissenter-1.0.2.dist-info/entry_points.txt +2 -0
dissent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.1"
|
dissent/cli.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, List
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.rule import Rule
|
|
11
|
+
from rich.tree import Tree
|
|
12
|
+
|
|
13
|
+
from .config import DissentConfig, ModelConfig, RoundConfig, load_config
|
|
14
|
+
from .detect import KNOWN_PROVIDERS, detect_api_keys, detect_clis, detect_ollama_models, infer_auth
|
|
15
|
+
from .runner import run_all_rounds
|
|
16
|
+
from .synthesis import synthesize
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
help="dissenter — multi-LLM debate engine for architectural decisions.",
|
|
20
|
+
add_completion=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
out = Console()
|
|
24
|
+
err = Console(stderr=True)
|
|
25
|
+
|
|
26
|
+
_DEFAULT_DEBATE_ROLES = ["skeptic", "contrarian", "pragmatist", "devil's advocate", "analyst"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Config builders for inline flags ─────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def _config_from_flags(
|
|
32
|
+
models: list[str],
|
|
33
|
+
chairman: str | None,
|
|
34
|
+
output_dir: Path | None,
|
|
35
|
+
) -> DissentConfig:
|
|
36
|
+
clis = detect_clis()
|
|
37
|
+
debate_models: list[ModelConfig] = []
|
|
38
|
+
for i, spec in enumerate(models):
|
|
39
|
+
# Format: model_id or model_id@role
|
|
40
|
+
if "@" in spec:
|
|
41
|
+
model_id, role = spec.rsplit("@", 1)
|
|
42
|
+
else:
|
|
43
|
+
model_id = spec
|
|
44
|
+
role = _DEFAULT_DEBATE_ROLES[i % len(_DEFAULT_DEBATE_ROLES)]
|
|
45
|
+
extra = {"api_base": "http://localhost:11434"} if model_id.startswith("ollama/") else {}
|
|
46
|
+
debate_models.append(
|
|
47
|
+
ModelConfig(id=model_id, role=role, auth=infer_auth(model_id, clis), extra=extra)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
chair_id = chairman or models[0].split("@")[0]
|
|
51
|
+
chair_extra = {"api_base": "http://localhost:11434"} if chair_id.startswith("ollama/") else {}
|
|
52
|
+
chairman_model = ModelConfig(
|
|
53
|
+
id=chair_id, role="chairman",
|
|
54
|
+
auth=infer_auth(chair_id, clis), extra=chair_extra,
|
|
55
|
+
)
|
|
56
|
+
return DissentConfig(
|
|
57
|
+
output_dir=output_dir or Path("decisions"),
|
|
58
|
+
rounds=[
|
|
59
|
+
RoundConfig(name="debate", models=debate_models),
|
|
60
|
+
RoundConfig(name="final", models=[chairman_model]),
|
|
61
|
+
],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _config_from_quick(output_dir: Path | None) -> DissentConfig:
|
|
66
|
+
ollama_models = detect_ollama_models()
|
|
67
|
+
if not ollama_models:
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
"--quick requires local Ollama models. "
|
|
70
|
+
"Install Ollama and run 'ollama pull mistral' first."
|
|
71
|
+
)
|
|
72
|
+
debate_models = [
|
|
73
|
+
ModelConfig(
|
|
74
|
+
id=f"ollama/{m}",
|
|
75
|
+
role=_DEFAULT_DEBATE_ROLES[i % len(_DEFAULT_DEBATE_ROLES)],
|
|
76
|
+
extra={"api_base": "http://localhost:11434"},
|
|
77
|
+
)
|
|
78
|
+
for i, m in enumerate(ollama_models)
|
|
79
|
+
]
|
|
80
|
+
chairman = ModelConfig(
|
|
81
|
+
id=f"ollama/{ollama_models[0]}",
|
|
82
|
+
role="chairman",
|
|
83
|
+
extra={"api_base": "http://localhost:11434"},
|
|
84
|
+
)
|
|
85
|
+
return DissentConfig(
|
|
86
|
+
output_dir=output_dir or Path("decisions"),
|
|
87
|
+
rounds=[
|
|
88
|
+
RoundConfig(name="debate", models=debate_models),
|
|
89
|
+
RoundConfig(name="final", models=[chairman]),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Commands ──────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
def ask(
|
|
98
|
+
question: str = typer.Argument(..., help="The question to debate"),
|
|
99
|
+
config: Optional[Path] = typer.Option(None, "--config", "-c", help="Config file path"),
|
|
100
|
+
output_dir: Optional[Path] = typer.Option(None, "--output", "-o", help="Output directory"),
|
|
101
|
+
model: Optional[List[str]] = typer.Option(
|
|
102
|
+
None, "--model", "-m",
|
|
103
|
+
help="model_id[@role] for the debate round — repeat for multiple models",
|
|
104
|
+
),
|
|
105
|
+
chairman: Optional[str] = typer.Option(
|
|
106
|
+
None, "--chairman", help="Model ID for the final (synthesis) round"
|
|
107
|
+
),
|
|
108
|
+
quick: bool = typer.Option(
|
|
109
|
+
False, "--quick", help="Auto-detect installed Ollama models and run immediately"
|
|
110
|
+
),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Run the full debate pipeline and synthesize a decision.
|
|
113
|
+
|
|
114
|
+
Config priority: --quick > --model/--chairman > --config / dissenter.toml
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
if quick:
|
|
118
|
+
cfg = _config_from_quick(output_dir)
|
|
119
|
+
elif model:
|
|
120
|
+
cfg = _config_from_flags(list(model), chairman, output_dir)
|
|
121
|
+
else:
|
|
122
|
+
cfg = load_config(config)
|
|
123
|
+
if output_dir:
|
|
124
|
+
cfg.output_dir = output_dir
|
|
125
|
+
except (FileNotFoundError, RuntimeError) as e:
|
|
126
|
+
err.print(f"[red]Error:[/red] {e}")
|
|
127
|
+
raise typer.Exit(1)
|
|
128
|
+
|
|
129
|
+
total_models = sum(len(r.active_models) for r in cfg.rounds)
|
|
130
|
+
err.print()
|
|
131
|
+
err.print(Rule("[bold]dissenter[/bold]"))
|
|
132
|
+
err.print(f" [dim]Question:[/dim] {question}")
|
|
133
|
+
err.print(f" [dim]Rounds :[/dim] {len(cfg.rounds)}")
|
|
134
|
+
err.print(f" [dim]Models :[/dim] {total_models} across all rounds")
|
|
135
|
+
err.print()
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
all_rounds, final_text, synthesis_results = asyncio.run(_main(question, cfg))
|
|
139
|
+
except RuntimeError as e:
|
|
140
|
+
err.print()
|
|
141
|
+
err.print(f"[red]Error:[/red] {e}")
|
|
142
|
+
err.print()
|
|
143
|
+
raise typer.Exit(1)
|
|
144
|
+
|
|
145
|
+
# Save outputs — everything lives under decisions/<timestamp>/
|
|
146
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
147
|
+
run_dir = cfg.output_dir / ts
|
|
148
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
|
|
150
|
+
for rr in all_rounds:
|
|
151
|
+
round_dir = run_dir / f"round_{rr.round_index + 1}_{rr.round_name}"
|
|
152
|
+
round_dir.mkdir()
|
|
153
|
+
for r in rr.results:
|
|
154
|
+
safe = r.model_id.replace("/", "_").replace(":", "_")
|
|
155
|
+
role_safe = r.role.replace(" ", "_").replace("'", "")
|
|
156
|
+
fname = f"{safe}__{role_safe}"
|
|
157
|
+
(round_dir / f"{fname}.md").write_text(r.content or "")
|
|
158
|
+
if r.error:
|
|
159
|
+
(round_dir / f"{fname}.err").write_text(r.error)
|
|
160
|
+
|
|
161
|
+
final_round_dir = run_dir / f"round_{len(all_rounds)}_{cfg.rounds[-1].name or 'final'}"
|
|
162
|
+
final_round_dir.mkdir(exist_ok=True)
|
|
163
|
+
for r in synthesis_results:
|
|
164
|
+
safe = r.model_id.replace("/", "_").replace(":", "_")
|
|
165
|
+
role_safe = r.role.replace(" ", "_").replace("'", "")
|
|
166
|
+
(final_round_dir / f"{safe}__{role_safe}.md").write_text(r.content or "")
|
|
167
|
+
|
|
168
|
+
output_file = run_dir / "decision.md"
|
|
169
|
+
output_file.write_text(final_text)
|
|
170
|
+
|
|
171
|
+
abs_file = output_file.absolute()
|
|
172
|
+
abs_dir = run_dir.absolute()
|
|
173
|
+
|
|
174
|
+
err.print()
|
|
175
|
+
err.print(Rule("[bold green]Done[/bold green]"))
|
|
176
|
+
err.print(f" [green]Decision :[/green] [link=file://{abs_file}]{abs_file}[/link]")
|
|
177
|
+
err.print(f" [dim]Run dir : [link=file://{abs_dir}]{abs_dir}[/link][/dim]")
|
|
178
|
+
err.print()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def init(
|
|
183
|
+
output: Path = typer.Option(Path("dissenter.toml"), "--output", "-o", help="Config file to create"),
|
|
184
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite without prompting"),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Interactively create a dissenter.toml config file."""
|
|
187
|
+
from .wizard import run_wizard
|
|
188
|
+
run_wizard(output, force, err)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@app.command()
|
|
192
|
+
def models() -> None:
|
|
193
|
+
"""Show detected local models, CLI tools, and API key status."""
|
|
194
|
+
from rich.table import Table
|
|
195
|
+
|
|
196
|
+
ollama = detect_ollama_models()
|
|
197
|
+
clis = detect_clis()
|
|
198
|
+
api_keys = detect_api_keys()
|
|
199
|
+
|
|
200
|
+
out.print()
|
|
201
|
+
|
|
202
|
+
# Ollama
|
|
203
|
+
out.print("[bold]Ollama (local)[/bold]")
|
|
204
|
+
if ollama:
|
|
205
|
+
for m in ollama:
|
|
206
|
+
out.print(f" [green]✓[/green] {m}")
|
|
207
|
+
else:
|
|
208
|
+
out.print(" [dim]no models — is ollama running? try: ollama pull mistral[/dim]")
|
|
209
|
+
|
|
210
|
+
# CLI tools
|
|
211
|
+
out.print()
|
|
212
|
+
out.print("[bold]CLI tools[/bold]")
|
|
213
|
+
for cli, path in clis.items():
|
|
214
|
+
if path:
|
|
215
|
+
out.print(f" [green]✓[/green] {cli:<10} [dim]{path}[/dim]")
|
|
216
|
+
else:
|
|
217
|
+
out.print(f" [dim]✗ {cli} not found[/dim]")
|
|
218
|
+
|
|
219
|
+
# API providers
|
|
220
|
+
out.print()
|
|
221
|
+
out.print("[bold]API providers[/bold]")
|
|
222
|
+
for provider, env_var in KNOWN_PROVIDERS.items():
|
|
223
|
+
has_key = api_keys[provider]
|
|
224
|
+
tick = "[green]✓[/green]" if has_key else "[dim]✗[/dim]"
|
|
225
|
+
note = "[green]key set[/green]" if has_key else f"[dim]export {env_var}[/dim]"
|
|
226
|
+
out.print(f" {tick} {provider:<18} {note}")
|
|
227
|
+
|
|
228
|
+
out.print()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.command()
|
|
232
|
+
def show(
|
|
233
|
+
config: Optional[Path] = typer.Option(None, "--config", "-c", help="Config file path"),
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Show the current configuration — rounds, models, roles."""
|
|
236
|
+
try:
|
|
237
|
+
cfg = load_config(config)
|
|
238
|
+
except FileNotFoundError as e:
|
|
239
|
+
err.print(f"[red]Error:[/red] {e}")
|
|
240
|
+
raise typer.Exit(1)
|
|
241
|
+
|
|
242
|
+
tree = Tree(f"[bold]dissenter[/bold] [dim]{cfg.output_dir}[/dim]")
|
|
243
|
+
for i, round_cfg in enumerate(cfg.rounds):
|
|
244
|
+
label = f"Round {i+1}: [cyan]{round_cfg.name or '(unnamed)'}[/cyan]"
|
|
245
|
+
if i == len(cfg.rounds) - 1:
|
|
246
|
+
label += " [yellow][final][/yellow]"
|
|
247
|
+
r_node = tree.add(label)
|
|
248
|
+
for m in round_cfg.models:
|
|
249
|
+
status = "[green]✓[/green]" if m.enabled else "[dim]—[/dim]"
|
|
250
|
+
r_node.add(
|
|
251
|
+
f"{status} [bold]{m.id}[/bold] [dim]role:[/dim] {m.role} "
|
|
252
|
+
f"[dim]auth:[/dim] {m.auth} [dim]timeout:[/dim] {m.timeout}s"
|
|
253
|
+
)
|
|
254
|
+
if round_cfg.combine_model:
|
|
255
|
+
r_node.add(f"[dim]combine via:[/dim] {round_cfg.combine_model}")
|
|
256
|
+
|
|
257
|
+
out.print(tree)
|
|
258
|
+
|
|
259
|
+
if cfg.role_distribution:
|
|
260
|
+
out.print()
|
|
261
|
+
out.print("[dim]Role distribution:[/dim]")
|
|
262
|
+
for role, weight in cfg.role_distribution.items():
|
|
263
|
+
out.print(f" {role}: {weight:.0%}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def _main(question: str, cfg: DissentConfig):
|
|
267
|
+
err.print(Rule("[dim]beginning debate[/dim]", style="dim"))
|
|
268
|
+
all_rounds = await run_all_rounds(cfg, question)
|
|
269
|
+
|
|
270
|
+
err.print()
|
|
271
|
+
err.print(Rule("[dim]synthesizing[/dim]", style="dim"))
|
|
272
|
+
|
|
273
|
+
final_text, synthesis_results = await synthesize(question, all_rounds, cfg)
|
|
274
|
+
return all_rounds, final_text, synthesis_results
|
dissent/config.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from platformdirs import user_config_dir
|
|
9
|
+
from pydantic import BaseModel, Field, model_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ModelConfig(BaseModel):
|
|
13
|
+
id: str
|
|
14
|
+
role: str = "analyst"
|
|
15
|
+
enabled: bool = True
|
|
16
|
+
timeout: int = 180
|
|
17
|
+
# API auth (default): litellm reads key from env var, or use api_key to override
|
|
18
|
+
api_key: str | None = None
|
|
19
|
+
# CLI auth: shell out to the provider's CLI tool instead of the API
|
|
20
|
+
# auth = "cli" uses the CLI's stored session (e.g. a logged-in `claude` install)
|
|
21
|
+
auth: str = "api" # "api" | "cli"
|
|
22
|
+
cli_command: str | None = None # e.g. "claude", "gemini"; inferred from provider if None
|
|
23
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RoundConfig(BaseModel):
|
|
27
|
+
name: str = ""
|
|
28
|
+
models: list[ModelConfig] = Field(default_factory=list)
|
|
29
|
+
combine_model: str | None = None
|
|
30
|
+
combine_timeout: int = 60
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def active_models(self) -> list[ModelConfig]:
|
|
34
|
+
return [m for m in self.models if m.enabled]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DissentConfig(BaseModel):
|
|
38
|
+
output_dir: Path = Path("decisions")
|
|
39
|
+
default_model: str | None = None
|
|
40
|
+
rounds: list[RoundConfig] = Field(default_factory=list)
|
|
41
|
+
role_distribution: dict[str, float] = Field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
@model_validator(mode="after")
|
|
44
|
+
def validate_rounds(self) -> "DissentConfig":
|
|
45
|
+
if not self.rounds:
|
|
46
|
+
raise ValueError("At least one [[rounds]] block must be configured")
|
|
47
|
+
last = self.rounds[-1]
|
|
48
|
+
n = len(last.active_models)
|
|
49
|
+
if n not in (1, 2):
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Final round must have exactly 1 or 2 enabled models, got {n}. "
|
|
52
|
+
"Use 1 model with role 'chairman', or 2 models with a combine_model."
|
|
53
|
+
)
|
|
54
|
+
if n == 2 and last.combine_model is None:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"Final round with 2 models requires combine_model to be set "
|
|
57
|
+
"(the model that merges their dual recommendations side-by-side)."
|
|
58
|
+
)
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_dual_final(self) -> bool:
|
|
63
|
+
return len(self.rounds[-1].active_models) == 2
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config(path: Path | None = None) -> DissentConfig:
|
|
67
|
+
candidates: list[Path] = []
|
|
68
|
+
if path:
|
|
69
|
+
candidates.append(path)
|
|
70
|
+
candidates.append(Path("dissenter.toml"))
|
|
71
|
+
candidates.append(Path(user_config_dir("dissenter")) / "config.toml")
|
|
72
|
+
|
|
73
|
+
for candidate in candidates:
|
|
74
|
+
if candidate.exists():
|
|
75
|
+
data = tomllib.loads(candidate.read_text())
|
|
76
|
+
return DissentConfig.model_validate(data)
|
|
77
|
+
|
|
78
|
+
raise FileNotFoundError(
|
|
79
|
+
"No dissenter.toml found. Create one with `dissenter init` or pass --config <path>."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def assign_random_roles(
|
|
84
|
+
model_ids: list[str],
|
|
85
|
+
distribution: dict[str, float],
|
|
86
|
+
) -> list[tuple[str, str]]:
|
|
87
|
+
"""Randomly assign roles from a weighted distribution to a list of model IDs.
|
|
88
|
+
|
|
89
|
+
Returns list of (model_id, role) pairs.
|
|
90
|
+
"""
|
|
91
|
+
if not distribution:
|
|
92
|
+
raise ValueError("role_distribution must be non-empty for random assignment")
|
|
93
|
+
roles = list(distribution.keys())
|
|
94
|
+
weights = list(distribution.values())
|
|
95
|
+
assigned = random.choices(roles, weights=weights, k=len(model_ids))
|
|
96
|
+
return list(zip(model_ids, assigned))
|
dissent/detect.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
# Known providers and the env var that holds their API key
|
|
8
|
+
KNOWN_PROVIDERS: dict[str, str] = {
|
|
9
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
10
|
+
"openai": "OPENAI_API_KEY",
|
|
11
|
+
"gemini": "GEMINI_API_KEY",
|
|
12
|
+
"mistral": "MISTRAL_API_KEY",
|
|
13
|
+
"groq": "GROQ_API_KEY",
|
|
14
|
+
"cohere": "CO_API_KEY",
|
|
15
|
+
"together_ai": "TOGETHERAI_API_KEY",
|
|
16
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def detect_ollama_models() -> list[str]:
|
|
21
|
+
"""Return list of locally installed Ollama model names."""
|
|
22
|
+
try:
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
["ollama", "list"],
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
timeout=5,
|
|
28
|
+
)
|
|
29
|
+
if result.returncode != 0:
|
|
30
|
+
return []
|
|
31
|
+
lines = result.stdout.strip().splitlines()
|
|
32
|
+
models: list[str] = []
|
|
33
|
+
for line in lines[1:]: # skip NAME header
|
|
34
|
+
parts = line.split()
|
|
35
|
+
if parts:
|
|
36
|
+
models.append(parts[0])
|
|
37
|
+
return models
|
|
38
|
+
except Exception:
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def detect_clis() -> dict[str, str | None]:
|
|
43
|
+
"""Return mapping of CLI name -> absolute path (None if not found)."""
|
|
44
|
+
return {
|
|
45
|
+
"claude": shutil.which("claude"),
|
|
46
|
+
"gemini": shutil.which("gemini"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def detect_api_keys() -> dict[str, bool]:
|
|
51
|
+
"""Return mapping of provider name -> whether its env var is set."""
|
|
52
|
+
return {
|
|
53
|
+
provider: bool(os.environ.get(env_var))
|
|
54
|
+
for provider, env_var in KNOWN_PROVIDERS.items()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def infer_auth(model_id: str, clis: dict[str, str | None]) -> str:
|
|
59
|
+
"""Return 'cli' if a CLI tool is available for this model's provider, else 'api'."""
|
|
60
|
+
provider = model_id.split("/")[0]
|
|
61
|
+
if provider == "anthropic" and clis.get("claude"):
|
|
62
|
+
return "cli"
|
|
63
|
+
if provider in ("gemini", "google") and clis.get("gemini"):
|
|
64
|
+
return "cli"
|
|
65
|
+
return "api"
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
name = "conservative"
|
|
2
|
+
description = "Pragmatic executor — safest proven path"
|
|
3
|
+
prompt = "Your role is the pragmatic executor. Given all preceding debate, recommend the safest, most proven path forward. Favor what can be shipped, maintained, and reversed if wrong. Be terse — your output will be placed side-by-side with the liberal view."
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
name = "liberal"
|
|
2
|
+
description = "Ambitious visionary — boldest high-upside path"
|
|
3
|
+
prompt = "Your role is the ambitious visionary. Given all preceding debate, recommend the boldest, highest-upside path forward. Favor what could be transformative if it works. Be terse — your output will be placed side-by-side with the conservative view."
|
dissent/roles.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_ROLES_DIR = Path(__file__).parent / "roles"
|
|
7
|
+
|
|
8
|
+
_FALLBACK_PROMPT = (
|
|
9
|
+
"Your role is balanced analyst. Be rigorous and cite specific trade-offs "
|
|
10
|
+
"with concrete numbers and real-world examples where possible."
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_roles() -> dict[str, str]:
|
|
15
|
+
"""Return mapping of role name → prompt text, loaded from roles/*.toml."""
|
|
16
|
+
roles: dict[str, str] = {}
|
|
17
|
+
for path in _ROLES_DIR.glob("*.toml"):
|
|
18
|
+
data = tomllib.loads(path.read_text())
|
|
19
|
+
name = data.get("name", path.stem)
|
|
20
|
+
prompt = data.get("prompt", _FALLBACK_PROMPT)
|
|
21
|
+
roles[name] = prompt
|
|
22
|
+
return roles
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_prompt(role: str, roles: dict[str, str] | None = None) -> str:
|
|
26
|
+
"""Return the prompt for a role, falling back to analyst if unknown."""
|
|
27
|
+
if roles is None:
|
|
28
|
+
roles = load_roles()
|
|
29
|
+
return roles.get(role, _FALLBACK_PROMPT)
|
dissent/runner.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import litellm
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.rule import Rule
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from .config import DissentConfig, ModelConfig, RoundConfig
|
|
16
|
+
from .roles import get_prompt, load_roles
|
|
17
|
+
|
|
18
|
+
litellm.suppress_debug_info = True
|
|
19
|
+
|
|
20
|
+
console = Console(stderr=True)
|
|
21
|
+
|
|
22
|
+
_SPECIALIST_PROMPT = """\
|
|
23
|
+
You are a senior software architect. {role_instruction}
|
|
24
|
+
|
|
25
|
+
{prior_context}Analyze this question:
|
|
26
|
+
{question}
|
|
27
|
+
|
|
28
|
+
Respond in this exact markdown structure:
|
|
29
|
+
|
|
30
|
+
## Recommendation
|
|
31
|
+
[Your clear, opinionated recommendation in 1-2 sentences]
|
|
32
|
+
|
|
33
|
+
## Pros
|
|
34
|
+
- [bullet]
|
|
35
|
+
|
|
36
|
+
## Cons / Risks
|
|
37
|
+
- [bullet]
|
|
38
|
+
|
|
39
|
+
## Critical Considerations
|
|
40
|
+
- [bullet]
|
|
41
|
+
|
|
42
|
+
## Recent Developments (2024-2026)
|
|
43
|
+
[Relevant ecosystem changes. Note if you have no web access.]
|
|
44
|
+
|
|
45
|
+
Be direct, technical, and opinionated. Engineers will act on this.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
_PRIOR_CONTEXT_TEMPLATE = """\
|
|
49
|
+
[Prior debate — Round {index}: "{name}"]
|
|
50
|
+
|
|
51
|
+
{responses}
|
|
52
|
+
|
|
53
|
+
[End prior debate]
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ModelResult:
|
|
60
|
+
model_id: str
|
|
61
|
+
role: str
|
|
62
|
+
round_name: str
|
|
63
|
+
content: str = ""
|
|
64
|
+
elapsed: float = 0.0
|
|
65
|
+
error: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def success(self) -> bool:
|
|
69
|
+
return bool(self.content) and self.error is None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def word_count(self) -> int:
|
|
73
|
+
return len(self.content.split())
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def short_id(self) -> str:
|
|
77
|
+
return self.model_id.split("/")[-1]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class RoundResult:
|
|
82
|
+
round_name: str
|
|
83
|
+
round_index: int
|
|
84
|
+
results: list[ModelResult] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def successful(self) -> list[ModelResult]:
|
|
88
|
+
return [r for r in self.results if r.success]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_prior_context(prior_rounds: list[RoundResult]) -> str:
|
|
92
|
+
if not prior_rounds:
|
|
93
|
+
return ""
|
|
94
|
+
parts = []
|
|
95
|
+
for rr in prior_rounds:
|
|
96
|
+
responses = "\n\n".join(
|
|
97
|
+
f"**{r.short_id}** (role: {r.role}):\n{r.content}"
|
|
98
|
+
for r in rr.successful
|
|
99
|
+
)
|
|
100
|
+
parts.append(
|
|
101
|
+
_PRIOR_CONTEXT_TEMPLATE.format(
|
|
102
|
+
index=rr.round_index + 1,
|
|
103
|
+
name=rr.round_name,
|
|
104
|
+
responses=responses,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return "".join(parts)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Known provider → CLI command mappings
|
|
111
|
+
_PROVIDER_CLI: dict[str, str] = {
|
|
112
|
+
"anthropic": "claude",
|
|
113
|
+
"gemini": "gemini",
|
|
114
|
+
"google": "gemini",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _infer_cli(model_id: str) -> str | None:
|
|
119
|
+
provider = model_id.split("/")[0]
|
|
120
|
+
return _PROVIDER_CLI.get(provider)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _query_model_cli(cfg: ModelConfig, prompt: str) -> str:
|
|
124
|
+
"""Query a model via its CLI tool, using the CLI's stored session auth."""
|
|
125
|
+
cli = cfg.cli_command or _infer_cli(cfg.id)
|
|
126
|
+
if not cli:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"No CLI command known for provider '{cfg.id.split('/')[0]}'. "
|
|
129
|
+
"Set cli_command in config (e.g. cli_command = \"claude\")."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# All CLIs: pass prompt via stdin with non-interactive/print flag
|
|
133
|
+
# claude: `claude --print` gemini: `gemini` (add more as needed)
|
|
134
|
+
cli_args: list[str] = [cli]
|
|
135
|
+
if cli == "claude":
|
|
136
|
+
cli_args += ["--print"]
|
|
137
|
+
|
|
138
|
+
proc = await asyncio.create_subprocess_exec(
|
|
139
|
+
*cli_args,
|
|
140
|
+
stdin=asyncio.subprocess.PIPE,
|
|
141
|
+
stdout=asyncio.subprocess.PIPE,
|
|
142
|
+
stderr=asyncio.subprocess.PIPE,
|
|
143
|
+
)
|
|
144
|
+
stdout, stderr = await proc.communicate(input=prompt.encode())
|
|
145
|
+
if proc.returncode != 0:
|
|
146
|
+
err_text = stderr.decode().strip()
|
|
147
|
+
raise RuntimeError(err_text or f"'{cli}' exited with code {proc.returncode}")
|
|
148
|
+
return stdout.decode().strip()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _classify_error(exc: Exception) -> str:
|
|
152
|
+
msg = str(exc)
|
|
153
|
+
t = type(exc).__name__
|
|
154
|
+
if "AuthenticationError" in t or "authentication" in msg.lower() or "api key" in msg.lower() or "Missing API Key" in msg:
|
|
155
|
+
provider = msg.split(":")[0].strip() if ":" in msg else "provider"
|
|
156
|
+
return f"missing/invalid API key ({provider}) — set the env var or add api_key to config"
|
|
157
|
+
if "NotFoundError" in t or "model not found" in msg.lower() or (
|
|
158
|
+
"pull" in msg.lower() and "ollama" in msg.lower()
|
|
159
|
+
):
|
|
160
|
+
return "model not installed — run 'ollama pull <model>' first"
|
|
161
|
+
if "APIConnectionError" in t or "Connection refused" in msg or "OllamaError" in t:
|
|
162
|
+
if "not found" in msg.lower() or "try pulling" in msg.lower():
|
|
163
|
+
return "model not installed — run 'ollama pull <model>' first"
|
|
164
|
+
if "ollama" in msg.lower() or "11434" in msg:
|
|
165
|
+
return "cannot reach Ollama — is 'ollama serve' running?"
|
|
166
|
+
return f"connection failed — is the server running? ({msg[:80]})"
|
|
167
|
+
if "RateLimitError" in t:
|
|
168
|
+
return "rate limited — wait and retry, or switch models"
|
|
169
|
+
if "ContextWindowExceededError" in t or "context length" in msg.lower():
|
|
170
|
+
return "context window exceeded — debate history too long for this model"
|
|
171
|
+
return msg[:120]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def _query_model(
|
|
175
|
+
cfg: ModelConfig,
|
|
176
|
+
round_name: str,
|
|
177
|
+
question: str,
|
|
178
|
+
prior_context: str,
|
|
179
|
+
role_prompts: dict[str, str],
|
|
180
|
+
) -> ModelResult:
|
|
181
|
+
role_instruction = get_prompt(cfg.role, role_prompts)
|
|
182
|
+
prompt = _SPECIALIST_PROMPT.format(
|
|
183
|
+
role_instruction=role_instruction,
|
|
184
|
+
prior_context=prior_context,
|
|
185
|
+
question=question,
|
|
186
|
+
)
|
|
187
|
+
start = time.monotonic()
|
|
188
|
+
result = ModelResult(model_id=cfg.id, role=cfg.role, round_name=round_name)
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
if cfg.auth == "cli":
|
|
192
|
+
result.content = await asyncio.wait_for(
|
|
193
|
+
_query_model_cli(cfg, prompt),
|
|
194
|
+
timeout=cfg.timeout,
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
kwargs: dict = {
|
|
198
|
+
"model": cfg.id,
|
|
199
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
200
|
+
**cfg.extra,
|
|
201
|
+
}
|
|
202
|
+
if cfg.api_key:
|
|
203
|
+
kwargs["api_key"] = cfg.api_key
|
|
204
|
+
response = await asyncio.wait_for(
|
|
205
|
+
litellm.acompletion(**kwargs),
|
|
206
|
+
timeout=cfg.timeout,
|
|
207
|
+
)
|
|
208
|
+
result.content = response.choices[0].message.content or ""
|
|
209
|
+
result.elapsed = time.monotonic() - start
|
|
210
|
+
except asyncio.TimeoutError:
|
|
211
|
+
result.error = f"timed out after {cfg.timeout}s"
|
|
212
|
+
result.elapsed = float(cfg.timeout)
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
result.error = _classify_error(exc)
|
|
215
|
+
result.elapsed = time.monotonic() - start
|
|
216
|
+
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _status_table(
|
|
221
|
+
round_name: str,
|
|
222
|
+
round_index: int,
|
|
223
|
+
results: dict[str, ModelResult],
|
|
224
|
+
done: set[str],
|
|
225
|
+
start_times: dict[str, float],
|
|
226
|
+
) -> Table:
|
|
227
|
+
now = time.monotonic()
|
|
228
|
+
table = Table(
|
|
229
|
+
title=f"Round {round_index + 1}: {round_name}",
|
|
230
|
+
show_header=True,
|
|
231
|
+
header_style="bold",
|
|
232
|
+
box=None,
|
|
233
|
+
padding=(0, 1),
|
|
234
|
+
)
|
|
235
|
+
table.add_column("Model", min_width=26)
|
|
236
|
+
table.add_column("Role", min_width=18)
|
|
237
|
+
table.add_column("Time", justify="right", min_width=6)
|
|
238
|
+
table.add_column("Status", min_width=22)
|
|
239
|
+
|
|
240
|
+
for key, result in results.items():
|
|
241
|
+
elapsed = result.elapsed if key in done else now - start_times[key]
|
|
242
|
+
elapsed_str = f"{elapsed:.0f}s"
|
|
243
|
+
|
|
244
|
+
if key not in done:
|
|
245
|
+
status = Text("⠸ running", style="yellow")
|
|
246
|
+
elif result.error:
|
|
247
|
+
status = Text(f"✗ {result.error[:35]}", style="red")
|
|
248
|
+
else:
|
|
249
|
+
status = Text(f"✓ ~{result.word_count} words", style="green")
|
|
250
|
+
|
|
251
|
+
table.add_row(result.short_id, result.role, elapsed_str, status)
|
|
252
|
+
|
|
253
|
+
return table
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def run_round(
|
|
257
|
+
round_cfg: RoundConfig,
|
|
258
|
+
round_index: int,
|
|
259
|
+
question: str,
|
|
260
|
+
prior_rounds: list[RoundResult],
|
|
261
|
+
role_prompts: dict[str, str],
|
|
262
|
+
) -> RoundResult:
|
|
263
|
+
active = round_cfg.active_models
|
|
264
|
+
prior_context = _build_prior_context(prior_rounds)
|
|
265
|
+
round_name = round_cfg.name or f"round_{round_index + 1}"
|
|
266
|
+
|
|
267
|
+
# Use (model_id, role, index) as unique key to support same model with different roles
|
|
268
|
+
keys = [f"{m.id}::{m.role}::{i}" for i, m in enumerate(active)]
|
|
269
|
+
results: dict[str, ModelResult] = {
|
|
270
|
+
k: ModelResult(model_id=m.id, role=m.role, round_name=round_name)
|
|
271
|
+
for k, m in zip(keys, active)
|
|
272
|
+
}
|
|
273
|
+
done: set[str] = set()
|
|
274
|
+
start_times: dict[str, float] = {k: time.monotonic() for k in keys}
|
|
275
|
+
|
|
276
|
+
async def run_and_track(key: str, cfg: ModelConfig) -> None:
|
|
277
|
+
result = await _query_model(cfg, round_name, question, prior_context, role_prompts)
|
|
278
|
+
results[key] = result
|
|
279
|
+
done.add(key)
|
|
280
|
+
|
|
281
|
+
tasks = [asyncio.create_task(run_and_track(k, m)) for k, m in zip(keys, active)]
|
|
282
|
+
|
|
283
|
+
with Live(console=console, refresh_per_second=4, transient=False) as live:
|
|
284
|
+
while not all(t.done() for t in tasks):
|
|
285
|
+
live.update(_status_table(round_name, round_index, results, done, start_times))
|
|
286
|
+
await asyncio.sleep(0.25)
|
|
287
|
+
live.update(_status_table(round_name, round_index, results, done, start_times))
|
|
288
|
+
|
|
289
|
+
round_result = RoundResult(round_name=round_name, round_index=round_index)
|
|
290
|
+
round_result.results = list(results.values())
|
|
291
|
+
return round_result
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def run_all_rounds(
|
|
295
|
+
cfg: DissentConfig,
|
|
296
|
+
question: str,
|
|
297
|
+
) -> list[RoundResult]:
|
|
298
|
+
role_prompts = load_roles()
|
|
299
|
+
all_results: list[RoundResult] = []
|
|
300
|
+
|
|
301
|
+
for i, round_cfg in enumerate(cfg.rounds):
|
|
302
|
+
active = round_cfg.active_models
|
|
303
|
+
if not active:
|
|
304
|
+
console.print(f"[yellow]Round {i+1} '{round_cfg.name}' has no enabled models, skipping.[/yellow]")
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
console.print()
|
|
308
|
+
console.print(Rule(f"[bold]Round {i+1} of {len(cfg.rounds)}: {round_cfg.name or ''}[/bold] ({len(active)} models)", style="dim"))
|
|
309
|
+
|
|
310
|
+
rr = await run_round(round_cfg, i, question, all_results, role_prompts)
|
|
311
|
+
all_results.append(rr)
|
|
312
|
+
|
|
313
|
+
if not rr.successful:
|
|
314
|
+
raise RuntimeError(
|
|
315
|
+
f"All models failed in round {i+1} '{round_cfg.name}'. Cannot continue."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return all_results
|
dissent/synthesis.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
import litellm
|
|
7
|
+
|
|
8
|
+
from .config import DissentConfig, ModelConfig, RoundConfig
|
|
9
|
+
from .roles import get_prompt, load_roles
|
|
10
|
+
from .runner import ModelResult, RoundResult, _query_model_cli
|
|
11
|
+
|
|
12
|
+
_SYNTHESIS_PROMPT = """\
|
|
13
|
+
You are a principal architect writing a formal Architectural Decision Record (ADR).
|
|
14
|
+
|
|
15
|
+
Multiple AI models debated across {n_rounds} round(s). Here are all their outputs:
|
|
16
|
+
|
|
17
|
+
{all_round_outputs}
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
Synthesize into an ADR using this EXACT structure (do not deviate):
|
|
22
|
+
|
|
23
|
+
# ADR: [derive a concise title]
|
|
24
|
+
|
|
25
|
+
**Date:** {date}
|
|
26
|
+
**Status:** Proposed
|
|
27
|
+
**Debate rounds:** {n_rounds} | **Models consulted:** {n_models_total}
|
|
28
|
+
|
|
29
|
+
## Context
|
|
30
|
+
[2-3 sentences on the problem and why this decision matters]
|
|
31
|
+
|
|
32
|
+
## Consensus
|
|
33
|
+
[What most/all models agreed on — high-confidence signals]
|
|
34
|
+
|
|
35
|
+
## Disagreements
|
|
36
|
+
[Where models diverged. For each: what was the disagreement, why it matters, what context would resolve it]
|
|
37
|
+
|
|
38
|
+
## Options Considered
|
|
39
|
+
|
|
40
|
+
| Option | Pros | Cons | Risk Level |
|
|
41
|
+
|--------|------|------|------------|
|
|
42
|
+
|
|
43
|
+
## Decision
|
|
44
|
+
**[The recommendation in one clear sentence.]**
|
|
45
|
+
|
|
46
|
+
[2-3 paragraphs of rationale: draw on consensus, resolve disagreements, be explicit about what assumptions this decision rests on]
|
|
47
|
+
|
|
48
|
+
## Consequences
|
|
49
|
+
|
|
50
|
+
### Positive
|
|
51
|
+
- [bullet]
|
|
52
|
+
|
|
53
|
+
### Risks
|
|
54
|
+
- [bullet]
|
|
55
|
+
|
|
56
|
+
### Mitigations
|
|
57
|
+
- [bullet]
|
|
58
|
+
|
|
59
|
+
## Open Questions
|
|
60
|
+
[Points that require your specific context — team size, existing stack, constraints — that no model could know]
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
*Synthesized by dissenter from {n_models_total} model responses across {n_rounds} round(s)*
|
|
64
|
+
|
|
65
|
+
Be ruthlessly clear, technically precise, and opinionated. No filler.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
_DUAL_ARBITER_PROMPT = """\
|
|
69
|
+
You are a senior architect. Your role: {role_instruction}
|
|
70
|
+
|
|
71
|
+
All preceding debate is below. Read it carefully, then write your recommendation.
|
|
72
|
+
|
|
73
|
+
{all_round_outputs}
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
Question: {question}
|
|
78
|
+
|
|
79
|
+
Write a terse, opinionated recommendation. Your output will be shown side-by-side with another architect's view.
|
|
80
|
+
|
|
81
|
+
## Your Recommendation
|
|
82
|
+
[1 clear sentence]
|
|
83
|
+
|
|
84
|
+
## Why
|
|
85
|
+
[2-3 bullet points max]
|
|
86
|
+
|
|
87
|
+
## Risks
|
|
88
|
+
[1-2 bullet points max]
|
|
89
|
+
|
|
90
|
+
## What would change your answer
|
|
91
|
+
[1 sentence]
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
_COMBINE_PROMPT = """\
|
|
95
|
+
Two senior architects reviewed the same debate and provided their recommendations.
|
|
96
|
+
Format them side by side for the human to compare and decide.
|
|
97
|
+
|
|
98
|
+
CONSERVATIVE view (pragmatic executor — safest proven path):
|
|
99
|
+
{conservative_output}
|
|
100
|
+
|
|
101
|
+
LIBERAL view (ambitious visionary — boldest high-upside path):
|
|
102
|
+
{liberal_output}
|
|
103
|
+
|
|
104
|
+
Format as this EXACT structure:
|
|
105
|
+
|
|
106
|
+
# Dual Recommendation: {title}
|
|
107
|
+
|
|
108
|
+
**Date:** {date}
|
|
109
|
+
**Question:** {question}
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Conservative View — Ship It
|
|
114
|
+
{conservative_output}
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Liberal View — Go Bold
|
|
119
|
+
{liberal_output}
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Key Divergence
|
|
124
|
+
[2-3 sentences: what is the core difference between these two views, and what specific context (team size, risk tolerance, timeline, existing stack) would make one more appropriate than the other]
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _format_all_rounds(all_rounds: list[RoundResult]) -> str:
|
|
129
|
+
parts = []
|
|
130
|
+
for rr in all_rounds:
|
|
131
|
+
header = f"### Round {rr.round_index + 1}: {rr.round_name}"
|
|
132
|
+
parts.append(header)
|
|
133
|
+
for r in rr.successful:
|
|
134
|
+
parts.append(f"\n**{r.short_id}** (role: *{r.role}*)\n\n{r.content}")
|
|
135
|
+
parts.append("\n---")
|
|
136
|
+
return "\n".join(parts)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _call_model(cfg: ModelConfig, prompt: str) -> str:
|
|
140
|
+
if cfg.auth == "cli":
|
|
141
|
+
return await asyncio.wait_for(
|
|
142
|
+
_query_model_cli(cfg, prompt),
|
|
143
|
+
timeout=float(cfg.timeout),
|
|
144
|
+
)
|
|
145
|
+
kwargs: dict = {
|
|
146
|
+
"model": cfg.id,
|
|
147
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
148
|
+
**(cfg.extra or {}),
|
|
149
|
+
}
|
|
150
|
+
if cfg.api_key:
|
|
151
|
+
kwargs["api_key"] = cfg.api_key
|
|
152
|
+
response = await asyncio.wait_for(
|
|
153
|
+
litellm.acompletion(**kwargs),
|
|
154
|
+
timeout=float(cfg.timeout),
|
|
155
|
+
)
|
|
156
|
+
return response.choices[0].message.content or ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def synthesize(
|
|
160
|
+
question: str,
|
|
161
|
+
all_rounds: list[RoundResult],
|
|
162
|
+
cfg: DissentConfig,
|
|
163
|
+
) -> tuple[str, list[ModelResult]]:
|
|
164
|
+
"""
|
|
165
|
+
Returns (final_text, final_model_results).
|
|
166
|
+
final_model_results contains the synthesis model outputs for saving to debug dir.
|
|
167
|
+
"""
|
|
168
|
+
role_prompts = load_roles()
|
|
169
|
+
final_round = cfg.rounds[-1]
|
|
170
|
+
active = final_round.active_models
|
|
171
|
+
all_round_outputs = _format_all_rounds(all_rounds[:-1] if len(all_rounds) == len(cfg.rounds) else all_rounds)
|
|
172
|
+
|
|
173
|
+
# Count total models consulted across all debate rounds (not final)
|
|
174
|
+
debate_rounds = all_rounds[:-1] if len(all_rounds) >= len(cfg.rounds) else all_rounds
|
|
175
|
+
n_models_total = sum(len(rr.successful) for rr in all_rounds)
|
|
176
|
+
|
|
177
|
+
if len(active) == 1:
|
|
178
|
+
# Single chairman/arbiter
|
|
179
|
+
arbiter = active[0]
|
|
180
|
+
prompt = _SYNTHESIS_PROMPT.format(
|
|
181
|
+
question=question,
|
|
182
|
+
all_round_outputs=_format_all_rounds(all_rounds),
|
|
183
|
+
date=date.today().isoformat(),
|
|
184
|
+
n_rounds=len(all_rounds),
|
|
185
|
+
n_models_total=n_models_total,
|
|
186
|
+
)
|
|
187
|
+
content = await _call_model(arbiter, prompt)
|
|
188
|
+
result = ModelResult(
|
|
189
|
+
model_id=arbiter.id, role=arbiter.role, round_name=final_round.name or "final",
|
|
190
|
+
content=content, elapsed=0.0,
|
|
191
|
+
)
|
|
192
|
+
return content, [result]
|
|
193
|
+
|
|
194
|
+
else:
|
|
195
|
+
# Dual arbiter: conservative + liberal
|
|
196
|
+
debate_context = _format_all_rounds(all_rounds)
|
|
197
|
+
|
|
198
|
+
async def call_arbiter(m) -> ModelResult:
|
|
199
|
+
role_instruction = get_prompt(m.role, role_prompts)
|
|
200
|
+
prompt = _DUAL_ARBITER_PROMPT.format(
|
|
201
|
+
role_instruction=role_instruction,
|
|
202
|
+
all_round_outputs=debate_context,
|
|
203
|
+
question=question,
|
|
204
|
+
)
|
|
205
|
+
content = await _call_model(m, prompt)
|
|
206
|
+
return ModelResult(
|
|
207
|
+
model_id=m.id, role=m.role, round_name=final_round.name or "final",
|
|
208
|
+
content=content, elapsed=0.0,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
results = await asyncio.gather(*[call_arbiter(m) for m in active])
|
|
212
|
+
|
|
213
|
+
# Identify conservative vs liberal
|
|
214
|
+
conservative_result = next((r for r in results if r.role == "conservative"), results[0])
|
|
215
|
+
liberal_result = next((r for r in results if r.role == "liberal"), results[1])
|
|
216
|
+
|
|
217
|
+
# Combine side-by-side
|
|
218
|
+
title = question[:60] + ("..." if len(question) > 60 else "")
|
|
219
|
+
combine_prompt = _COMBINE_PROMPT.format(
|
|
220
|
+
conservative_output=conservative_result.content,
|
|
221
|
+
liberal_output=liberal_result.content,
|
|
222
|
+
title=title,
|
|
223
|
+
date=date.today().isoformat(),
|
|
224
|
+
question=question,
|
|
225
|
+
)
|
|
226
|
+
combine_cfg = ModelConfig(
|
|
227
|
+
id=final_round.combine_model,
|
|
228
|
+
role="combiner",
|
|
229
|
+
timeout=final_round.combine_timeout,
|
|
230
|
+
)
|
|
231
|
+
combined = await _call_model(combine_cfg, combine_prompt)
|
|
232
|
+
return combined, list(results)
|
dissent/wizard.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from .detect import detect_clis, detect_ollama_models, infer_auth
|
|
10
|
+
|
|
11
|
+
_DEBATE_ROLES = [
|
|
12
|
+
"skeptic",
|
|
13
|
+
"contrarian",
|
|
14
|
+
"pragmatist",
|
|
15
|
+
"devil's advocate",
|
|
16
|
+
"analyst",
|
|
17
|
+
"researcher",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _collect_model(
|
|
22
|
+
label: str,
|
|
23
|
+
clis: dict[str, str | None],
|
|
24
|
+
default_role: str,
|
|
25
|
+
console: Console,
|
|
26
|
+
) -> dict:
|
|
27
|
+
console.print(f"\n [bold]{label}[/bold]")
|
|
28
|
+
model_id = typer.prompt(" ID (e.g. ollama/mistral, anthropic/claude-sonnet-4-6)")
|
|
29
|
+
role = typer.prompt(" Role", default=default_role)
|
|
30
|
+
auth = typer.prompt(" Auth (api/cli)", default=infer_auth(model_id, clis))
|
|
31
|
+
timeout = int(typer.prompt(" Timeout (s)", default="180"))
|
|
32
|
+
m: dict = {"id": model_id, "role": role, "auth": auth, "timeout": timeout}
|
|
33
|
+
if model_id.startswith("ollama/"):
|
|
34
|
+
api_base = typer.prompt(" Ollama API base", default="http://localhost:11434")
|
|
35
|
+
m["extra"] = {"api_base": api_base}
|
|
36
|
+
return m
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _render_toml(rounds_data: list[dict], output_dir: str) -> str:
|
|
40
|
+
lines = [
|
|
41
|
+
"# dissenter — multi-LLM debate engine",
|
|
42
|
+
f'output_dir = "{output_dir}"',
|
|
43
|
+
"",
|
|
44
|
+
]
|
|
45
|
+
for i, rd in enumerate(rounds_data):
|
|
46
|
+
is_final = i == len(rounds_data) - 1
|
|
47
|
+
label = f"Final round: {rd['name']}" if is_final else f"Round {i + 1}: {rd['name']}"
|
|
48
|
+
fill = "─" * max(4, 52 - len(label))
|
|
49
|
+
lines.append(f"# ── {label} {fill}")
|
|
50
|
+
lines.append("[[rounds]]")
|
|
51
|
+
lines.append(f'name = "{rd["name"]}"')
|
|
52
|
+
if rd.get("combine_model"):
|
|
53
|
+
lines.append(f'combine_model = "{rd["combine_model"]}"')
|
|
54
|
+
lines.append(f'combine_timeout = {rd.get("combine_timeout", 60)}')
|
|
55
|
+
lines.append("")
|
|
56
|
+
for m in rd["models"]:
|
|
57
|
+
lines.append("[[rounds.models]]")
|
|
58
|
+
lines.append(f'id = "{m["id"]}"')
|
|
59
|
+
lines.append(f'role = "{m["role"]}"')
|
|
60
|
+
if m.get("auth") == "cli":
|
|
61
|
+
lines.append(f'auth = "cli"')
|
|
62
|
+
lines.append(f'timeout = {m["timeout"]}')
|
|
63
|
+
if m.get("extra"):
|
|
64
|
+
kv = ", ".join(f'{k} = "{v}"' for k, v in m["extra"].items())
|
|
65
|
+
lines.append(f'extra = {{ {kv} }}')
|
|
66
|
+
lines.append("")
|
|
67
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_wizard(output_path: Path, force: bool, console: Console) -> None:
|
|
71
|
+
"""Interactive config wizard. Writes a dissent.toml to output_path."""
|
|
72
|
+
clis = detect_clis()
|
|
73
|
+
ollama_models = detect_ollama_models()
|
|
74
|
+
|
|
75
|
+
env_lines: list[str] = []
|
|
76
|
+
for cli, path in clis.items():
|
|
77
|
+
if path:
|
|
78
|
+
env_lines.append(f" [green]✓[/green] {cli} CLI [dim]{path}[/dim]")
|
|
79
|
+
else:
|
|
80
|
+
env_lines.append(f" [dim]✗ {cli} CLI not found[/dim]")
|
|
81
|
+
if ollama_models:
|
|
82
|
+
env_lines.append(
|
|
83
|
+
f" [green]✓[/green] Ollama [dim]{', '.join(ollama_models)}[/dim]"
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
env_lines.append(" [dim]✗ Ollama not found or no models installed[/dim]")
|
|
87
|
+
|
|
88
|
+
console.print(Panel("\n".join(env_lines), title="[bold]dissenter init[/bold]", expand=False))
|
|
89
|
+
console.print()
|
|
90
|
+
|
|
91
|
+
if output_path.exists() and not force:
|
|
92
|
+
if not typer.confirm(f"{output_path} already exists. Overwrite?", default=False):
|
|
93
|
+
raise typer.Exit(0)
|
|
94
|
+
|
|
95
|
+
output_dir = typer.prompt("Output directory", default="decisions")
|
|
96
|
+
n_debate = int(typer.prompt("Debate rounds (not counting final)", default="1"))
|
|
97
|
+
|
|
98
|
+
rounds_data: list[dict] = []
|
|
99
|
+
|
|
100
|
+
for ri in range(n_debate):
|
|
101
|
+
console.print(f"\n[bold]── Round {ri + 1} ──[/bold]")
|
|
102
|
+
name = typer.prompt(" Name", default="debate" if ri == 0 else f"round_{ri + 1}")
|
|
103
|
+
n_models = int(typer.prompt(" Models", default="2"))
|
|
104
|
+
models = [
|
|
105
|
+
_collect_model(
|
|
106
|
+
f"Model {mi + 1}", clis, _DEBATE_ROLES[mi % len(_DEBATE_ROLES)], console
|
|
107
|
+
)
|
|
108
|
+
for mi in range(n_models)
|
|
109
|
+
]
|
|
110
|
+
rounds_data.append({"name": name, "models": models})
|
|
111
|
+
|
|
112
|
+
console.print("\n[bold]── Final Round ──[/bold]")
|
|
113
|
+
final_type = typer.prompt(" Type (chairman/dual)", default="chairman")
|
|
114
|
+
final_name = typer.prompt(" Name", default="final")
|
|
115
|
+
|
|
116
|
+
if final_type == "dual":
|
|
117
|
+
con = _collect_model("Conservative model", clis, "conservative", console)
|
|
118
|
+
con["role"] = "conservative"
|
|
119
|
+
lib = _collect_model("Liberal model", clis, "liberal", console)
|
|
120
|
+
lib["role"] = "liberal"
|
|
121
|
+
combine = typer.prompt(" Combine model ID")
|
|
122
|
+
combine_timeout = int(typer.prompt(" Combine timeout (s)", default="60"))
|
|
123
|
+
rounds_data.append({
|
|
124
|
+
"name": final_name,
|
|
125
|
+
"models": [con, lib],
|
|
126
|
+
"combine_model": combine,
|
|
127
|
+
"combine_timeout": combine_timeout,
|
|
128
|
+
})
|
|
129
|
+
else:
|
|
130
|
+
chair = _collect_model("Chairman model", clis, "chairman", console)
|
|
131
|
+
chair["role"] = "chairman"
|
|
132
|
+
rounds_data.append({"name": final_name, "models": [chair]})
|
|
133
|
+
|
|
134
|
+
toml_content = _render_toml(rounds_data, output_dir)
|
|
135
|
+
console.print("\n[bold]── Preview ──[/bold]\n")
|
|
136
|
+
console.print(toml_content)
|
|
137
|
+
|
|
138
|
+
if typer.confirm(f"Save to {output_path}?", default=True):
|
|
139
|
+
output_path.write_text(toml_content)
|
|
140
|
+
console.print(f"\n[green]✓[/green] Saved [bold]{output_path}[/bold]")
|
|
141
|
+
console.print(f" Run [bold]dissenter ask \"your question\"[/bold] to get started.")
|
|
142
|
+
else:
|
|
143
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dissenter
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Multi-LLM debate engine for architectural decisions
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: litellm>=1.62.0
|
|
7
|
+
Requires-Dist: platformdirs>=4.3.0
|
|
8
|
+
Requires-Dist: pydantic>=2.9.0
|
|
9
|
+
Requires-Dist: rich>=13.9.0
|
|
10
|
+
Requires-Dist: typer>=0.12.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
dissent/__init__.py,sha256=d4QHYmS_30j0hPN8NmNPnQ_Z0TphDRbu4MtQj9cT9e8,22
|
|
2
|
+
dissent/cli.py,sha256=MNK6ubB3Ra_pKkIbMDxUUbgRb4fTlt1-gvN4K_c1-BI,9869
|
|
3
|
+
dissent/config.py,sha256=5s4bGKAAB2xdQUGmLgFNYCxhtd1176YyJifNLT-aFQQ,3341
|
|
4
|
+
dissent/detect.py,sha256=M13r9v5GddSB-AgXtDtbHPXMAXw3jjY6XRuLD2_0x74,1904
|
|
5
|
+
dissent/roles.py,sha256=HOkeB8Rpw5-ykq1E7W2V9Kk0XK6Hdi9IrDVq58suXpM,921
|
|
6
|
+
dissent/runner.py,sha256=HAJ2KoygHCseZoAdbLRNhow5fxzPM3az5Y52nXV_y04,9969
|
|
7
|
+
dissent/synthesis.py,sha256=3e2SixvIuiPUq89-3BiX3BKBtBJQacsW3ZhcTM2060k,6858
|
|
8
|
+
dissent/wizard.py,sha256=6GYl8GgDPGIbY2AnWXcIM1BaD_b8a0YBZ6j-DIbkWFg,5538
|
|
9
|
+
dissent/roles/analyst.toml,sha256=5GovW6THlT_BG016C_MmCQLIX0DVrdpcgWtnumwOC9A,227
|
|
10
|
+
dissent/roles/chairman.toml,sha256=6L-ZAo3fvbHODBEem3qmRzelhYL4-dQ5n4J7Rd1JWH0,236
|
|
11
|
+
dissent/roles/conservative.toml,sha256=RMyy-AQuwFW_skck3c_5OF8yrOupymb2xL4vnmCkluw,342
|
|
12
|
+
dissent/roles/contrarian.toml,sha256=OiDYiD4bgwjB9SeupVp_rGXuSrrIeN3WVKxPgDLlHvw,228
|
|
13
|
+
dissent/roles/devils_advocate.toml,sha256=vaJnhVtNXBdAlyX8IlF_IO4TNQvqjQsvdr5zPd0VliU,234
|
|
14
|
+
dissent/roles/liberal.toml,sha256=rP5PsRLPgEF2_5W8sJc_lCwo09x51GRQe52lVlTbrCg,340
|
|
15
|
+
dissent/roles/pragmatist.toml,sha256=4hTm4mHVMo9Re6399qiz-WVBLr5D9iGyHD3LDSLDNRU,252
|
|
16
|
+
dissent/roles/researcher.toml,sha256=H7GQhwJXyhtwLeQKGfmLQ_-pzQ4C7V2onhtXCkEWAFU,263
|
|
17
|
+
dissent/roles/second_opinion.toml,sha256=BZu-Dw6Z1FJpzFjPR_Cqn0qAuRfATQeq7Y3wR0dvfuA,223
|
|
18
|
+
dissent/roles/skeptic.toml,sha256=p9lUoeH8FbmtYlEqVHBUVzfmLMzmcpDSAMW7lDXCzVU,248
|
|
19
|
+
dissenter-1.0.2.dist-info/METADATA,sha256=KN8V_NSxEo85Boan0yyWG-dI2VIp2LVQ4Ij_Y1wC34Y,407
|
|
20
|
+
dissenter-1.0.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
21
|
+
dissenter-1.0.2.dist-info/entry_points.txt,sha256=UnjlVR-sazqqILOfWbfpO8XeJN9UgikeRNOUb8HgZiI,46
|
|
22
|
+
dissenter-1.0.2.dist-info/RECORD,,
|