Coverage for src/ai_jury/scaffold.py: 100%
116 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:18 +0000
1"""Scaffold a ``jury.toml`` from agent selections (issue #107).
3Backs the ``jury init`` command: instead of hand-editing TOML, a user (or a
4script) picks agents/rounds/chair and this renders a valid config. The cloud
5agent templates reuse the **secure-by-default** entries from
6:data:`config.DEFAULT_CONFIG` (issue #100) so generated configs are safe; a
7``local`` template targets an OpenAI-compatible server (Ollama by default).
9Pure and deterministic: building the config dict and rendering it to TOML are
10side-effect-free, so they are fully unit-testable; the CLI layer owns prompting,
11availability detection, and writing the file.
12"""
14from __future__ import annotations
16from .config import DEFAULT_CONFIG
18_LOCAL_TEMPLATE = {
19 "name": "qwen",
20 "vendor": "local",
21 "model": "qwen2.5-coder:7b",
22 "endpoint": "http://localhost:11434/v1",
23}
25# Hosted-API templates (issue #430): no `command`/`endpoint` — see
26# adapters._HostedApiAdapter. `model` is left for the user to fill in (a
27# hardcoded model id here would go stale as vendors deprecate/rename models;
28# `validate_config` already warns when it's missing).
29_ANTHROPIC_API_TEMPLATE = {"name": "claude-api", "vendor": "anthropic-api", "model": ""}
30_OPENAI_API_TEMPLATE = {"name": "codex-api", "vendor": "openai-api", "model": ""}
31_GOOGLE_API_TEMPLATE = {"name": "gemini-api", "vendor": "google-api", "model": ""}
34def _from_default(name: str) -> dict | None:
35 for a in DEFAULT_CONFIG.get("agent", []):
36 if a.get("name") == name:
37 return dict(a)
38 return None
41def agent_templates() -> dict[str, dict]:
42 """Built-in agent templates keyed by short name (a fresh copy each call)."""
43 templates: dict[str, dict] = {}
44 for name in ("claude", "codex", "agy"):
45 tmpl = _from_default(name)
46 if tmpl is not None:
47 templates[name] = tmpl
48 templates["qwen"] = dict(_LOCAL_TEMPLATE)
49 templates["claude-api"] = dict(_ANTHROPIC_API_TEMPLATE)
50 templates["codex-api"] = dict(_OPENAI_API_TEMPLATE)
51 templates["gemini-api"] = dict(_GOOGLE_API_TEMPLATE)
52 return templates
55KNOWN_AGENTS: tuple[str, ...] = (
56 "claude", "codex", "agy", "qwen", "claude-api", "codex-api", "gemini-api",
57)
59# Substrings that hint a local model is code-oriented (preferred for reviews).
60_CODER_HINTS: tuple[str, ...] = ("coder", "code", "deepseek", "qwen")
63def pick_default_model(models: list[str]) -> str | None:
64 """Choose a sensible default from discovered local models (issue #109).
66 Prefers a code-oriented model (name contains 'coder'/'code'/etc.), else the
67 first listed; returns None for an empty list.
68 """
69 if not models:
70 return None
71 for m in models:
72 low = m.lower()
73 if any(h in low for h in _CODER_HINTS):
74 return m
75 return models[0]
78# Named setup presets (issue: easier config). Each gives default agents +
79# settings for a common intent; explicit flags / detected agents override the
80# `agents` value ("detected" = the agents available right now, "all" = every
81# known agent). Resolved by the CLI, which knows availability.
82PRESETS: dict[str, dict] = {
83 "offline": {"agents": ["qwen"], "rounds": 1, "verify": False},
84 "fast": {"agents": "detected", "rounds": 1, "verify": False},
85 "balanced": {"agents": "detected", "rounds": 2, "verify": True, "early_stop": True},
86 "thorough": {"agents": "all", "rounds": 2, "verify": True},
87}
90def build_config(
91 agents: list[str],
92 *,
93 rounds: int = 2,
94 chair: str | None = None,
95 verify: bool = True,
96 early_stop: bool | None = None,
97 local_model: str | None = None,
98 local_endpoint: str | None = None,
99 decision: str | None = None,
100 auto_depth: bool | None = None,
101 context_mode: str | None = None,
102 redact_secrets: bool | None = None,
103 ci_fail_on: list[str] | None = None,
104) -> dict:
105 """Build a jury config dict from selected agent names.
107 Raises ``ValueError`` on an unknown agent name or an empty selection. The
108 chair defaults to the first selected agent. Local agents pick up the
109 optional model/endpoint overrides.
111 The optional ``decision``/``auto_depth``/``context_mode``/``redact_secrets``/
112 ``ci_fail_on`` knobs (used by ``jury init --wizard``) are written ONLY when
113 not ``None`` — callers that omit them produce byte-identical output to before,
114 keeping the scaffolded file free of redundant built-in defaults.
115 """
116 templates = agent_templates()
117 chosen: list[dict] = []
118 seen: set[str] = set()
119 for name in agents:
120 if name in seen:
121 continue
122 tmpl = templates.get(name)
123 if tmpl is None:
124 raise ValueError(f"unknown agent '{name}'; choose from {', '.join(KNOWN_AGENTS)}")
125 entry = dict(tmpl)
126 if entry.get("vendor") == "local":
127 if local_model:
128 entry["model"] = local_model
129 if local_endpoint:
130 entry["endpoint"] = local_endpoint
131 chosen.append(entry)
132 seen.add(name)
134 if not chosen:
135 raise ValueError("select at least one agent")
137 if chair is None:
138 chair = chosen[0]["name"]
140 jury: dict = {"rounds": int(rounds), "chair": chair, "verify": bool(verify)}
141 if early_stop:
142 jury["early_stop"] = True
143 if auto_depth is not None:
144 jury["auto_depth"] = bool(auto_depth)
145 if decision is not None:
146 jury["decision"] = decision
147 if context_mode is not None or redact_secrets is not None:
148 context: dict = {}
149 if context_mode is not None:
150 context["mode"] = context_mode
151 if redact_secrets is not None:
152 context["redact_secrets"] = bool(redact_secrets)
153 jury["context"] = context
154 if ci_fail_on is not None:
155 jury["ci"] = {"fail_on": list(ci_fail_on)}
156 return {"jury": jury, "agent": chosen}
159def _scalar(value) -> str:
160 if isinstance(value, bool):
161 return "true" if value else "false"
162 if isinstance(value, int):
163 return str(value)
164 if isinstance(value, str):
165 escaped = value.replace("\\", "\\\\").replace('"', '\\"')
166 return f'"{escaped}"'
167 raise TypeError(f"cannot render TOML scalar of type {type(value).__name__}")
170def _render_value(value) -> str:
171 if isinstance(value, list):
172 return "[" + ", ".join(_scalar(v) for v in value) + "]"
173 return _scalar(value)
176# Stable key order for agent tables so output is deterministic and readable.
177_AGENT_KEY_ORDER = ("name", "vendor", "command", "endpoint", "model", "extra_args")
180def render_toml(config: dict) -> str:
181 """Render a jury config dict to ``jury.toml`` text (minimal, typed).
183 Handles exactly the value types this config uses (str/int/bool/list[str]).
184 Empty/None values are omitted so a local agent (no ``command``/``extra_args``)
185 stays clean.
186 """
187 lines = [
188 "# Generated by `jury init`. Edit freely — see docs/configuration.md",
189 "# for the full schema (rounds, ci gate, context policy, diff handling).",
190 "",
191 "[jury]",
192 ]
193 jury = config["jury"]
194 # Scalar [jury] keys in a stable, readable order. ``decision``/``auto_depth``
195 # are emitted here only when present (the wizard sets them on a non-default).
196 for key in ("rounds", "chair", "verify", "decision", "auto_depth", "early_stop", "max_rounds"):
197 if key in jury:
198 lines.append(f"{key} = {_render_value(jury[key])}")
199 lines.append("")
201 # Optional nested tables, written only when the wizard captured a non-default.
202 context = jury.get("context")
203 if context:
204 lines.append("[jury.context]")
205 for key in ("mode", "redact_secrets"):
206 if key in context:
207 lines.append(f"{key} = {_render_value(context[key])}")
208 lines.append("")
209 ci = jury.get("ci")
210 if ci and "fail_on" in ci:
211 lines.append("[jury.ci]")
212 lines.append(f"fail_on = {_render_value(ci['fail_on'])}")
213 lines.append("")
215 for agent in config["agent"]:
216 lines.append("[[agent]]")
217 for key in _AGENT_KEY_ORDER:
218 if key not in agent:
219 continue
220 value = agent[key]
221 if value in (None, "", []):
222 continue
223 lines.append(f"{key} = {_render_value(value)}")
224 lines.append("")
226 return "\n".join(lines).rstrip() + "\n"