Coverage for src/ai_jury/scaffold.py: 100%
110 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 20:29 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 20:29 +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"""
13from __future__ import annotations
15from .config import DEFAULT_CONFIG
17_LOCAL_TEMPLATE = {
18 "name": "qwen",
19 "vendor": "local",
20 "model": "qwen2.5-coder:7b",
21 "endpoint": "http://localhost:11434/v1",
22}
25def _from_default(name: str) -> dict | None:
26 for a in DEFAULT_CONFIG.get("agent", []):
27 if a.get("name") == name:
28 return dict(a)
29 return None
32def agent_templates() -> dict[str, dict]:
33 """Built-in agent templates keyed by short name (a fresh copy each call)."""
34 templates: dict[str, dict] = {}
35 for name in ("claude", "codex", "agy"):
36 tmpl = _from_default(name)
37 if tmpl is not None:
38 templates[name] = tmpl
39 templates["qwen"] = dict(_LOCAL_TEMPLATE)
40 return templates
43KNOWN_AGENTS: tuple[str, ...] = ("claude", "codex", "agy", "qwen")
45# Substrings that hint a local model is code-oriented (preferred for reviews).
46_CODER_HINTS: tuple[str, ...] = ("coder", "code", "deepseek", "qwen")
49def pick_default_model(models: list[str]) -> str | None:
50 """Choose a sensible default from discovered local models (issue #109).
52 Prefers a code-oriented model (name contains 'coder'/'code'/etc.), else the
53 first listed; returns None for an empty list.
54 """
55 if not models:
56 return None
57 for m in models:
58 low = m.lower()
59 if any(h in low for h in _CODER_HINTS):
60 return m
61 return models[0]
64# Named setup presets (issue: easier config). Each gives default agents +
65# settings for a common intent; explicit flags / detected agents override the
66# `agents` value ("detected" = the agents available right now, "all" = every
67# known agent). Resolved by the CLI, which knows availability.
68PRESETS: dict[str, dict] = {
69 "offline": {"agents": ["qwen"], "rounds": 1, "verify": False},
70 "fast": {"agents": "detected", "rounds": 1, "verify": False},
71 "balanced": {"agents": "detected", "rounds": 2, "verify": True, "early_stop": True},
72 "thorough": {"agents": "all", "rounds": 2, "verify": True},
73}
76def build_config(
77 agents: list[str],
78 *,
79 rounds: int = 2,
80 chair: str | None = None,
81 verify: bool = True,
82 early_stop: bool | None = None,
83 local_model: str | None = None,
84 local_endpoint: str | None = None,
85 decision: str | None = None,
86 auto_depth: bool | None = None,
87 context_mode: str | None = None,
88 redact_secrets: bool | None = None,
89 ci_fail_on: list[str] | None = None,
90) -> dict:
91 """Build a jury config dict from selected agent names.
93 Raises ``ValueError`` on an unknown agent name or an empty selection. The
94 chair defaults to the first selected agent. Local agents pick up the
95 optional model/endpoint overrides.
97 The optional ``decision``/``auto_depth``/``context_mode``/``redact_secrets``/
98 ``ci_fail_on`` knobs (used by ``jury init --wizard``) are written ONLY when
99 not ``None`` — callers that omit them produce byte-identical output to before,
100 keeping the scaffolded file free of redundant built-in defaults.
101 """
102 templates = agent_templates()
103 chosen: list[dict] = []
104 seen: set[str] = set()
105 for name in agents:
106 if name in seen:
107 continue
108 tmpl = templates.get(name)
109 if tmpl is None:
110 raise ValueError(
111 f"unknown agent '{name}'; choose from {', '.join(KNOWN_AGENTS)}"
112 )
113 entry = dict(tmpl)
114 if entry.get("vendor") == "local":
115 if local_model:
116 entry["model"] = local_model
117 if local_endpoint:
118 entry["endpoint"] = local_endpoint
119 chosen.append(entry)
120 seen.add(name)
122 if not chosen:
123 raise ValueError("select at least one agent")
125 if chair is None:
126 chair = chosen[0]["name"]
128 jury: dict = {"rounds": int(rounds), "chair": chair, "verify": bool(verify)}
129 if early_stop:
130 jury["early_stop"] = True
131 if auto_depth is not None:
132 jury["auto_depth"] = bool(auto_depth)
133 if decision is not None:
134 jury["decision"] = decision
135 if context_mode is not None or redact_secrets is not None:
136 context: dict = {}
137 if context_mode is not None:
138 context["mode"] = context_mode
139 if redact_secrets is not None:
140 context["redact_secrets"] = bool(redact_secrets)
141 jury["context"] = context
142 if ci_fail_on is not None:
143 jury["ci"] = {"fail_on": list(ci_fail_on)}
144 return {"jury": jury, "agent": chosen}
147def _scalar(value) -> str:
148 if isinstance(value, bool):
149 return "true" if value else "false"
150 if isinstance(value, int):
151 return str(value)
152 if isinstance(value, str):
153 escaped = value.replace("\\", "\\\\").replace('"', '\\"')
154 return f'"{escaped}"'
155 raise TypeError(f"cannot render TOML scalar of type {type(value).__name__}")
158def _render_value(value) -> str:
159 if isinstance(value, list):
160 return "[" + ", ".join(_scalar(v) for v in value) + "]"
161 return _scalar(value)
164# Stable key order for agent tables so output is deterministic and readable.
165_AGENT_KEY_ORDER = ("name", "vendor", "command", "endpoint", "model", "extra_args")
168def render_toml(config: dict) -> str:
169 """Render a jury config dict to ``jury.toml`` text (minimal, typed).
171 Handles exactly the value types this config uses (str/int/bool/list[str]).
172 Empty/None values are omitted so a local agent (no ``command``/``extra_args``)
173 stays clean.
174 """
175 lines = [
176 "# Generated by `jury init`. Edit freely — see docs/configuration.md",
177 "# for the full schema (rounds, ci gate, context policy, diff handling).",
178 "",
179 "[jury]",
180 ]
181 jury = config["jury"]
182 # Scalar [jury] keys in a stable, readable order. ``decision``/``auto_depth``
183 # are emitted here only when present (the wizard sets them on a non-default).
184 for key in ("rounds", "chair", "verify", "decision", "auto_depth", "early_stop", "max_rounds"):
185 if key in jury:
186 lines.append(f"{key} = {_render_value(jury[key])}")
187 lines.append("")
189 # Optional nested tables, written only when the wizard captured a non-default.
190 context = jury.get("context")
191 if context:
192 lines.append("[jury.context]")
193 for key in ("mode", "redact_secrets"):
194 if key in context:
195 lines.append(f"{key} = {_render_value(context[key])}")
196 lines.append("")
197 ci = jury.get("ci")
198 if ci and "fail_on" in ci:
199 lines.append("[jury.ci]")
200 lines.append(f"fail_on = {_render_value(ci['fail_on'])}")
201 lines.append("")
203 for agent in config["agent"]:
204 lines.append("[[agent]]")
205 for key in _AGENT_KEY_ORDER:
206 if key not in agent:
207 continue
208 value = agent[key]
209 if value in (None, "", []):
210 continue
211 lines.append(f"{key} = {_render_value(value)}")
212 lines.append("")
214 return "\n".join(lines).rstrip() + "\n"