Services¶
Every service module under app/services/ is single-purpose, pure-functional where possible, and includes a graceful offline fallback. Below is the contract surface for the LLM-flavored ones — what they take, what they return, what fails to.
Pattern: LLM service + fallback¶
The shape that recurs across review.py, hints.py, hamming.py, jds.py, external_capture.py, credentials.py:
def some_action(...) -> dict[str, Any]:
if not os.environ.get("ANTHROPIC_API_KEY"):
return fallback_shape # deterministic, often process-focused
try:
return claude_path(...) # the Sonnet/Haiku call
except Exception:
return fallback_shape # never let an LLM error crash a route
Routes always call the public entry point and never branch on availability — that's the service's job.
hints¶
hints.get_hint(problem, phase, current_notes, code, count_so_far, prior_hints)
Returns:
{
"tier": 1 | 2 | 3,
"tier_label": "nudge" | "direction" | "scaffolding",
"hint": "...",
"method": "claude" | "fallback",
"exhausted": False | True
}
The system prompt explicitly forbids tier-3 code expressions (the reference-solution disclosure is the escape hatch). prior_hints lets Claude avoid repeating itself across calls.
review¶
review.review_attempt(problem, attempt, voice_key="default")
Returns:
{
"method": "claude" | "skipped",
"model_used": "claude-sonnet-4-5" | None,
"summary_md": "...",
"per_phase": {phase: {"strength", "gap", "question"}, ...},
"overall_score": float,
"rubric_signals": dict | None, # only for interview_mode
"error_text": str | None,
"voice_key": "default" | "hamming" | ...
}
The voice_key selects a persona from app/services/voices.py to prepend to the system prompt. Default voice's persona is empty so existing behavior is preserved.
hamming (reflect)¶
hamming.summarize_week(...) → WeekSummary. Pure aggregation; no LLM.
hamming.generate(week, days=7) → dict with summary, prompts[], method, model_used. Anti-sycophancy by design.
readiness¶
Pure-functional, no LLM. readiness.compute(user_scores, jd_axis_weights, axes) → Readiness dataclass with:
readiness_pct(0–100)total_hours_neededgaps: list[AxisGap]— sorted by hours descendinghas_data: bool— False if no scores or no JD weightsnote: str | None— explanation when has_data is False
readiness.weeks_to_ready(total_hours, hours_per_week) → float weeks.
readiness.snapshot_for_template(readiness, axis_labels) → dict the Jinja template consumes. Splits gaps into needs_work and at_target.
voices¶
Registry of named-thinker personas. voices.get(key) always returns a Voice (never None — falls back to default). voices.apply_persona(base_system_prompt, voice) prepends the persona to a system prompt.
jd parse¶
jds.parse(source_text) → dict matching JD.axis_weights + signal_tags + summary + iac_profile + iac_evidence_md + method + model_used.
jds.fetch_from_url(url) → optional dict {title, org, source_text, url} or None on fetch failure.
jds.title_from_url(url) → clean title derived from URL slug, used as fallback when fetch fails. jds.is_url_title(s) → bool detector for the jd_title Jinja filter.
jds.interpret_iac_fit(jd_iac, user_iac) → 1-line natural-language alignment interpretation (small Haiku call; cached on jds.iac_fit_md).
external_capture¶
external_capture.fetch_body(url) → {page_title, meta, source_text} or None. Reuses the HTML-strip helper from jds.py.
external_capture.parse_with_claude(url, fetched, valid_axes) → {title, source, axes[], summary_md} or None on parse failure. Defends against code-fenced JSON; filters axes against the valid set.
scheduler¶
scheduler.pick_next(...) — chooses problems for the day. Reads:
- User's axis scores
- Recent attempts (for recall scoring + ramp counter)
- Active JD's
axis_weights+signal_tags easy_first_bias(ramp)
Returns a scored list of problems with breakdowns (the details field on the home Up-next card surfaces these).