Skip to content

Commit dd0ea25

Browse files
Merge feat/settings-providers-panel: providers panel, gen-quality fixes, scalable course list
Brings 20 commits to main: - Settings → LLM Providers admin panel (auth-kind taxonomy, Bedrock bearer, Ollama URL, per-provider test) + e2e selector fixes. - Content-gen fixes: Issue #1 deck write-root/read-root reconciliation (resolve_study_dirs + recursive discovery); MiniMax tool_result correction, inline-XML tool-call fallback, transient-retry; --max-retries harness knob. - Autonomous generate+judge+validate+report workflow (3 providers). - Scalable course list: publisher field, mode-split Flashcards/Quizzes panels, collapsible publisher groups, search, compact rows (+37 e2e tests).
2 parents e669fdb + 4e52f4c commit dd0ea25

36 files changed

Lines changed: 3668 additions & 226 deletions

packages/studyloop/src/studyloop/cli/_web.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,13 @@ def web(port: int, lan: bool, password: str, ttyd_port: int, dev: bool) -> None:
3939

4040
import secrets
4141

42-
from studyloop.settings import load_raw_config
42+
from studyloop.settings import resolve_study_dirs
4343

4444
study_dirs: list[str] = []
4545
with contextlib.suppress(Exception):
46-
study_dirs = load_raw_config().get("review", {}).get("directories", [])
46+
# Falls back to content.base_path when review.directories is unset, so
47+
# the review panels discover decks the generator just wrote.
48+
study_dirs = resolve_study_dirs()
4749

4850
# Resolve credentials: always read username from config; password from CLI > config > auto
4951
username = "study"

packages/studyloop/src/studyloop/content/generators/_retry.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,21 +79,42 @@ def call_with_correction(
7979
"""
8080
history_extension: list[dict[str, Any]] = []
8181
last_error: str | None = None
82+
last_transient: CardGenerationError | None = None
8283

8384
for attempt in range(max_retries + 1):
8485
ctx = CallContext[T](
8586
attempt=attempt,
8687
last_error=last_error,
8788
history_extension=history_extension,
8889
)
89-
tool_payload, new_history = call_fn(ctx)
90+
try:
91+
tool_payload, new_history = call_fn(ctx)
92+
except CardGenerationError as exc:
93+
# Transient call failure (e.g. a flaky provider returned an
94+
# unparseable / tool-less response, or a momentary transport blip).
95+
# Don't hard-fail the whole generation on a single bad emission —
96+
# retry within the same budget. Some providers (MiniMax M2.7) only
97+
# emit a valid tool call ~half the time, so one clean retry usually
98+
# succeeds. The history is left unchanged (the bad turn is dropped).
99+
last_transient = exc
100+
last_error = f"Previous attempt failed to produce a usable tool call: {exc}"
101+
continue
102+
103+
last_transient = None
90104
history_extension = new_history
91105

92106
try:
93107
return model_cls.model_validate(tool_payload)
94108
except ValidationError as exc:
95109
last_error = f"Tool input did not match schema: {exc.errors()!r}"
96110

111+
# Exhausted the budget. If the last failure was a transient call error,
112+
# surface that (it's more actionable than a stale schema error).
113+
if last_transient is not None:
114+
raise CardGenerationError(
115+
f"Generator failed to produce a usable tool call after "
116+
f"{max_retries + 1} attempts: {last_transient}"
117+
)
97118
raise CardGenerationError(
98119
f"Generator returned invalid payload after {max_retries + 1} attempts: {last_error}"
99120
)

packages/studyloop/src/studyloop/content/generators/anthropic_compat.py

Lines changed: 105 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from __future__ import annotations
3030

3131
import json
32+
import re
3233
from typing import TYPE_CHECKING, Any
3334

3435
import httpx
@@ -76,6 +77,48 @@
7677
_DEFAULT_MAX_TOKENS = 4096
7778

7879

80+
# MiniMax M2.7 sometimes emits the forced tool call as inline XML markup inside
81+
# a text block rather than a native Anthropic tool_use block, e.g.:
82+
# <minimax:tool_call>
83+
# <invoke name="emit_quiz_deck">
84+
# <parameter name="title">"DT"</parameter>
85+
# <parameter name="questions">[ ... JSON ... ]</parameter>
86+
# </invoke>
87+
# </minimax:tool_call>
88+
# Each <parameter> value is the JSON encoding of that key's value. We reassemble
89+
# them into the tool-input dict the schema expects.
90+
_INVOKE_RE = re.compile(r'<invoke\s+name="(?P<name>[^"]+)"\s*>(?P<body>.*?)</invoke>', re.DOTALL)
91+
_PARAM_RE = re.compile(
92+
r'<parameter\s+name="(?P<key>[^"]+)"\s*>(?P<val>.*?)</parameter>', re.DOTALL
93+
)
94+
95+
96+
def _parse_inline_tool_call(text: str, expected_tool_name: str) -> dict[str, Any] | None:
97+
"""Parse MiniMax's inline ``<invoke>`` markup into a tool-input dict.
98+
99+
Returns the assembled tool input, or ``None`` if the text contains no
100+
matching ``<invoke name="<expected_tool_name>">`` block (so a genuine
101+
text-only refusal still surfaces as an error upstream).
102+
"""
103+
for m in _INVOKE_RE.finditer(text):
104+
if m.group("name") != expected_tool_name:
105+
continue
106+
body = m.group("body")
107+
params: dict[str, Any] = {}
108+
for pm in _PARAM_RE.finditer(body):
109+
key = pm.group("key")
110+
raw = pm.group("val").strip()
111+
try:
112+
params[key] = json.loads(raw)
113+
except (ValueError, json.JSONDecodeError):
114+
# Fall back to the raw string when a value isn't JSON-encoded
115+
# (e.g. a bare title); the schema validator catches real misses.
116+
params[key] = raw
117+
if params:
118+
return params
119+
return None
120+
121+
79122
class AnthropicCompatGenerator:
80123
"""Card generator backed by any Anthropic Messages-compatible API.
81124
@@ -196,17 +239,29 @@ def _generate(
196239
tool_choice = {"type": "tool", "name": tool_name}
197240

198241
def call(ctx: CallContext) -> tuple[Any, list[dict[str, Any]]]:
199-
messages = list(base_messages) + list(ctx.history_extension)
200-
if ctx.last_error is not None:
201-
messages.append(
202-
{
203-
"role": "user",
204-
"content": (
205-
f"The previous tool call did not validate against the schema: "
206-
f"{ctx.last_error}. Re-emit a corrected payload that conforms."
207-
),
208-
}
209-
)
242+
history = list(ctx.history_extension)
243+
# On a retry, fill in the real validation error on the tool_result
244+
# placeholder that the previous attempt appended. The Anthropic
245+
# Messages spec requires the user turn after an assistant tool_use
246+
# to be a tool_result for that tool_use id; MiniMax's /anthropic
247+
# shim enforces this strictly (a plain-text user turn -> error
248+
# 2013). Carrying the correction AS a tool_result keeps the
249+
# alternation valid across all providers.
250+
if ctx.last_error is not None and history:
251+
last = history[-1]
252+
if (
253+
last.get("role") == "user"
254+
and isinstance(last.get("content"), list)
255+
and last["content"]
256+
and last["content"][0].get("type") == "tool_result"
257+
):
258+
last["content"][0]["content"] = (
259+
f"The previous tool call did not validate against the "
260+
f"schema: {ctx.last_error}. Re-emit a corrected payload "
261+
f"that conforms exactly."
262+
)
263+
264+
messages = list(base_messages) + history
210265

211266
payload: dict[str, Any] = {
212267
"model": self._model.id,
@@ -224,18 +279,27 @@ def call(ctx: CallContext) -> tuple[Any, list[dict[str, Any]]]:
224279
}
225280

226281
resp = self._post_messages(payload)
227-
tool_payload, assistant_turn = self._extract_tool_payload(resp, tool_name)
228-
new_history = list(ctx.history_extension) + [assistant_turn]
229-
if ctx.last_error is not None:
230-
new_history.append(
231-
{
232-
"role": "user",
233-
"content": (
234-
f"The previous tool call did not validate against the schema: "
235-
f"{ctx.last_error}. Re-emit a corrected payload that conforms."
236-
),
237-
}
238-
)
282+
tool_payload, assistant_turn, tool_use_id = self._extract_tool_payload(
283+
resp, tool_name
284+
)
285+
# Append the assistant's tool_use turn followed immediately by a
286+
# placeholder tool_result so the history stays protocol-valid. If
287+
# validation fails, the NEXT attempt overwrites the placeholder
288+
# content with the actual error (above).
289+
new_history = [
290+
*history,
291+
assistant_turn,
292+
{
293+
"role": "user",
294+
"content": [
295+
{
296+
"type": "tool_result",
297+
"tool_use_id": tool_use_id,
298+
"content": "Acknowledged.",
299+
}
300+
],
301+
},
302+
]
239303
return tool_payload, new_history
240304

241305
return call_with_correction(
@@ -266,14 +330,18 @@ def _post_messages(self, payload: dict[str, Any]) -> dict[str, Any]:
266330

267331
def _extract_tool_payload(
268332
self, resp: dict[str, Any], expected_tool_name: str
269-
) -> tuple[dict[str, Any], dict[str, Any]]:
333+
) -> tuple[dict[str, Any], dict[str, Any], str]:
270334
"""Pull the ``tool_use`` block out of an Anthropic Messages response.
271335
272336
Anthropic returns ``content`` as a list of typed blocks. With
273337
forced tool_choice, exactly one of those blocks should be a
274338
``{type:"tool_use", name, input}`` block. We assemble the
275339
assistant turn from the full content list so the correction-turn
276340
history sees what the model actually said.
341+
342+
Returns ``(tool_input, assistant_turn, tool_use_id)``. The
343+
``tool_use_id`` lets the caller build a protocol-valid
344+
``tool_result`` correction turn that references this exact call.
277345
"""
278346
content = resp.get("content")
279347
if not isinstance(content, list) or not content:
@@ -289,11 +357,20 @@ def _extract_tool_payload(
289357
break
290358

291359
if tool_use_block is None:
360+
# MiniMax M2.7 intermittently narrates the tool call as inline XML
361+
# markup inside a text block instead of emitting a native tool_use
362+
# block (a reasoning-model quirk). Parse that fallback before giving
363+
# up, so generation doesn't fail ~half the time on this provider.
292364
text_blocks = [b for b in content if b.get("type") == "text"]
293-
text = (text_blocks[0].get("text", "") if text_blocks else "")[:200]
365+
full_text = "".join(b.get("text", "") for b in text_blocks)
366+
inline = _parse_inline_tool_call(full_text, expected_tool_name)
367+
if inline is not None:
368+
assistant_turn = {"role": "assistant", "content": content}
369+
return inline, assistant_turn, "toolu_inline"
370+
preview = full_text[:200]
294371
raise CardGenerationError(
295372
f"{self._profile.label} response missing tool_use block "
296-
f"(expected {expected_tool_name!r}). Text content was: {text!r}"
373+
f"(expected {expected_tool_name!r}). Text content was: {preview!r}"
297374
)
298375

299376
if tool_use_block.get("name") != expected_tool_name:
@@ -314,7 +391,8 @@ def _extract_tool_payload(
314391
)
315392

316393
assistant_turn = {"role": "assistant", "content": content}
317-
return args, assistant_turn
394+
tool_use_id = str(tool_use_block.get("id") or "toolu_correction")
395+
return args, assistant_turn, tool_use_id
318396

319397

320398
__all__ = ["AnthropicCompatGenerator"]

packages/studyloop/src/studyloop/content/generators/bedrock.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,20 @@ def _build_client(self, *, region: str, model: str) -> tuple[Any, str]:
158158
retries={"max_attempts": 1, "mode": "standard"},
159159
)
160160

161+
# Bearer-token fast-path. When AWS_BEARER_TOKEN_BEDROCK is set (a
162+
# Bedrock API key, injected from the encrypted store at generation
163+
# time), boto3's credential chain uses it directly — no named profile,
164+
# and no STS get_caller_identity precheck (a bearer token is scoped to
165+
# bedrock:CallWithBearerToken, not STS, so the precheck would wrongly
166+
# fail). An invalid token fails fast on the first Converse call.
167+
import os
168+
169+
if os.environ.get("AWS_BEARER_TOKEN_BEDROCK", "").strip():
170+
return (
171+
boto3.client("bedrock-runtime", region_name=region, config=boto_config),
172+
model,
173+
)
174+
161175
profiles: list[str] = [self._bedrock.profile]
162176
if self._bedrock.profile_fallback and self._bedrock.profile_fallback not in profiles:
163177
profiles.append(self._bedrock.profile_fallback)

packages/studyloop/src/studyloop/content/generators/provider_profiles.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class ProviderProfile:
7070

7171
slug: str
7272
label: str
73-
adapter: Literal["openai_compat", "anthropic_compat"]
73+
adapter: Literal["openai_compat", "anthropic_compat", "bedrock", "ollama"]
7474
base_url: str
7575
auth_env: str
7676
models: list[ModelEntry] = field(default_factory=list)
@@ -203,6 +203,71 @@ class ProviderProfile:
203203
),
204204
],
205205
),
206+
# Bedrock uses boto3 + the Converse API, handled by the separate
207+
# BedrockGenerator (not one of the two HTTP adapters). It authenticates
208+
# with an AWS profile/SigV4 OR an AWS_BEARER_TOKEN_BEDROCK bearer token —
209+
# availability is computed in the providers route, not via auth_env alone.
210+
# Model IDs are cross-region inference profiles; verify in the Bedrock
211+
# console before relying on them.
212+
"bedrock": ProviderProfile(
213+
slug="bedrock",
214+
label="AWS Bedrock",
215+
adapter="bedrock",
216+
base_url="",
217+
auth_env="AWS_PROFILE",
218+
models=[
219+
ModelEntry(
220+
id="us.anthropic.claude-haiku-4-5-20251001-v1:0",
221+
label="Claude Haiku 4.5 (Bedrock)",
222+
cost_tier="cheap",
223+
notes="Cross-region inference profile",
224+
),
225+
ModelEntry(
226+
id="us.anthropic.claude-sonnet-4-6-20251101-v1:0",
227+
label="Claude Sonnet 4.6 (Bedrock)",
228+
cost_tier="balanced",
229+
notes="Cross-region inference profile",
230+
),
231+
],
232+
),
233+
# Ollama is local and keyless. Handled by OllamaGenerator. The model list
234+
# is a modest-footprint recommendation for machines without a large
235+
# unified-memory GPU; the endpoint is user-editable (stored as the
236+
# ``ollama_base_url`` secret). Per-host-profile model suggestions via the
237+
# autoagent harness are a future phase.
238+
"ollama": ProviderProfile(
239+
slug="ollama",
240+
label="Ollama (local)",
241+
adapter="ollama",
242+
base_url="http://localhost:11434",
243+
auth_env="",
244+
models=[
245+
ModelEntry(
246+
id="qwen2.5:7b",
247+
label="Qwen 2.5 7B",
248+
cost_tier="cheap",
249+
notes="Default; good structured output",
250+
),
251+
ModelEntry(
252+
id="llama3.2:3b",
253+
label="Llama 3.2 3B",
254+
cost_tier="cheap",
255+
notes="Fast, low RAM",
256+
),
257+
ModelEntry(
258+
id="gemma3:4b",
259+
label="Gemma 3 4B",
260+
cost_tier="cheap",
261+
notes="Google; efficient",
262+
),
263+
ModelEntry(
264+
id="phi3.5:3.8b",
265+
label="Phi-3.5 3.8B",
266+
cost_tier="cheap",
267+
notes="Microsoft; efficient",
268+
),
269+
],
270+
),
206271
}
207272

208273

0 commit comments

Comments
 (0)