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

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

13from __future__ import annotations 

14 

15from .config import DEFAULT_CONFIG 

16 

17_LOCAL_TEMPLATE = { 

18 "name": "qwen", 

19 "vendor": "local", 

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

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

22} 

23 

24 

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 

30 

31 

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 

41 

42 

43KNOWN_AGENTS: tuple[str, ...] = ("claude", "codex", "agy", "qwen") 

44 

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

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

47 

48 

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

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

51 

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] 

62 

63 

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} 

74 

75 

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. 

92 

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. 

96 

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) 

121 

122 if not chosen: 

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

124 

125 if chair is None: 

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

127 

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} 

145 

146 

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

156 

157 

158def _render_value(value) -> str: 

159 if isinstance(value, list): 

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

161 return _scalar(value) 

162 

163 

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

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

166 

167 

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

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

170 

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

188 

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

202 

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

213 

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