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

1"""Scaffold a ``jury.toml`` from agent selections (issue #107). 

2 

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). 

8 

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""" 

13 

14from __future__ import annotations 

15 

16from .config import DEFAULT_CONFIG 

17 

18_LOCAL_TEMPLATE = { 

19 "name": "qwen", 

20 "vendor": "local", 

21 "model": "qwen2.5-coder:7b", 

22 "endpoint": "http://localhost:11434/v1", 

23} 

24 

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": ""} 

32 

33 

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 

39 

40 

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 

53 

54 

55KNOWN_AGENTS: tuple[str, ...] = ( 

56 "claude", "codex", "agy", "qwen", "claude-api", "codex-api", "gemini-api", 

57) 

58 

59# Substrings that hint a local model is code-oriented (preferred for reviews). 

60_CODER_HINTS: tuple[str, ...] = ("coder", "code", "deepseek", "qwen") 

61 

62 

63def pick_default_model(models: list[str]) -> str | None: 

64 """Choose a sensible default from discovered local models (issue #109). 

65 

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] 

76 

77 

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} 

88 

89 

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. 

106 

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. 

110 

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) 

133 

134 if not chosen: 

135 raise ValueError("select at least one agent") 

136 

137 if chair is None: 

138 chair = chosen[0]["name"] 

139 

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} 

157 

158 

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__}") 

168 

169 

170def _render_value(value) -> str: 

171 if isinstance(value, list): 

172 return "[" + ", ".join(_scalar(v) for v in value) + "]" 

173 return _scalar(value) 

174 

175 

176# Stable key order for agent tables so output is deterministic and readable. 

177_AGENT_KEY_ORDER = ("name", "vendor", "command", "endpoint", "model", "extra_args") 

178 

179 

180def render_toml(config: dict) -> str: 

181 """Render a jury config dict to ``jury.toml`` text (minimal, typed). 

182 

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("") 

200 

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("") 

214 

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("") 

225 

226 return "\n".join(lines).rstrip() + "\n"