Working paper on Sprijin360 onboarding v2 and the prompt-audit skills for Claude Code
Sprijin360 (sprijin360.ro) lists psychologists, psychotherapists, nutritionists, speech therapists and coaches. It grew out of LogoMio, a speech-therapy platform DBM AI built in 2025, and was built in two to three weeks in early 2026 on Django, LangGraph, PostgreSQL and Redis. About 40 therapists have registered.
The first onboarding (v1) was an eight-step wizard with a form per step. It worked, but it asked practitioners to write marketing copy about themselves, which most of them find slow and hard. Version 2 replaces the forms with a short conversation, mostly in Romanian, by text or voice. The system pulls structured data out of the answers and writes the profile for them. They review a pre-filled form next to a live preview, change what they want, and submit it for admin approval. v2 writes to the same models as v1 (Category, Service, Team, TeamMember) and reuses its save, review and publish code, so nothing downstream had to change.
Design constraint: a cheap, non-reasoning model. The agent is built to run on a small, cheap model with no reasoning step: gpt-4o-mini, for all three roles (extractor, responder, writers). That keeps each onboarding cheap and each reply quick. A model in this class is good at narrow language tasks, such as pulling facts out of a paragraph into JSON or writing one question. It is unreliable at the things a reasoning model would manage on its own over a long chat: remembering what it already has, judging when it has enough, and noticing that it is asking the same thing again. So the model gets only narrow tasks, and the goal and the bookkeeping stay in code:
The goal itself (enough fields at 7 or more) is never handed to the model as something to pursue. It lives in the evaluator, and each turn the responder is only told which fields are still missing.
The conversation fills a session record with nine fields, which the code calls ingredients. Each field holds the raw snippets the practitioner said, a clean merged version written by the model, and a score from 0 to 10. A field counts as covered at 7. Fields have a priority and one of three categories.
Table 1. The nine fields, in priority order.
| # | Field | What it holds | Category |
|---|---|---|---|
| 1 | B_type | Type of practice: clinical psychology, psychotherapy, counselling, nutrition, physiotherapy, coaching | must |
| 2 | C_what_they_do | Services offered and the problems they help with | must |
| 3 | S_services_list | Structured list: name, price, duration, online or in person | must |
| 4 | D_audience | Typical clients: adults, children, couples, specific situations | should |
| 5 | E_usp | What sets them apart: approach, experience, hours, online sessions | should |
| 6 | H_specializations | Methods and specialisms: CBT, EMDR, systemic, trauma, addiction | should |
| 7 | S_schedule | Working days and hours | should |
| 8 | F_story | How the practice started, why they became a therapist | nice |
| 9 | G_approach | Therapeutic philosophy, what a typical session looks like | nice |
Each turn makes two model calls with plain code between them. The models only read a free-text answer and write the next question. Everything about flow (scoring services, deciding when to stop, breaking loops) is ordinary Python that is unit-tested and logged.
C_what_they_do is at 7 or more and either
(a) there is at least one service, one should-field at 7 and another at 6, or (b) the practitioner skipped
services and three should-fields are at 7. Otherwise it returns the fields under 7 in priority order and
notes which fields crossed 7 this turn.
When the chat ends, a LangGraph StateGraph drafts the texts. It has seven profile nodes (title, subtitle,
short description, full description, team description, team expertise, approach) and three nodes per service
(subtitle, short and full description). All of them start from START together and meet at a
finalize node, which saves through the v1 save functions. Each node has its own prompt and gets
only the fields it needs; a service node also gets every snippet that names that service. A practitioner with
one service gets ten writers; one with three services gets sixteen. Each node writes to its own state key, and
service results and errors are combined with reducers (a deep merge and list concatenation), so no node waits
for another.
Each text comes back with a weak flag. It is set when any source field scored under 5, or, for a service text, when the chat never mentioned that service. Weak texts are highlighted on the review form so the practitioner knows what to check first.
finalize.
Seven profile writers plus three per service: ten writers for one service, sixteen for three.
Every prompt and UI string is a database row (V2Prompt) keyed by slug and language, with a version
number, an active flag and an activation time. A prompt can be edited in Django admin, and the logs from the
period it was live can be matched to that version. Model, temperature and token limit are stored per role
(extractor, responder, generator) in V2ModelConfig; all three are seeded with gpt-4o-mini.
Logs mask email addresses and phone numbers.
The v2 app has about 150 unit tests for the merge, scoring, stop and save logic. Conversation quality was tested with simulated practitioners, three ways:
simulate_onboarding command). gpt-4o-mini plays a terse, verbose,
bored, confused or careless-typing practitioner against the real pipeline, with real database sessions.Table 2. Problems found by the 15 March 2026 full-flow runs, and the fixes.
| Problem | Seen in | Fix |
|---|---|---|
| Plural “we” in texts for solo practitioners | 13 / 13 | Singular-voice rule added to five writer prompts |
| Year founded always empty | 13 / 13 | Key mismatch (year vs year_founded) fixed in the generator |
| Irrelevant tags, e.g. occupational therapy suggested to nutritionists | ~8 / 13 | Domain stop words added to the tag suggester |
| CBT named in texts when the person never mentioned it | 5 / 13 | “Do not assume methodology” added to the writer prompts |
Re-runs for a nutritionist, a speech therapist and a psychodynamic therapist came back clean on all four. Average lengths across the 13 profiles: short description 615 characters, full description 2,412. The LLM-simulated runs found one more thing: the simulator let two conversations end at turns 2 and 3, under the 4-turn minimum. The simulator copies the view's stop logic instead of calling it, and the copy had drifted. The live view was correct.
A unit test tells you a view returned 200. It does not tell you that a prompt edit made the bot start inventing prices. prompt-audit tests behaviour: a fixed set of scenarios goes through the real pipeline, an LLM judge scores each answer against written criteria, and the scores are compared with earlier runs. It is built as two Claude Code skills, which run the process with the developer, and about 640 lines of Python, which do the mechanical parts.
generalist-en).
It holds a constitution (purpose, manner, a role description, expected behaviours, a risk surface), where every
prompt lives, and a pinned judge model and judge-prompt version.This skill only writes files; it never runs a scenario. It reads the endpoint from its entry function down to every LLM call and lists every prompt source it finds: database rows, files, constants, tool docstrings (the model reads them), Pydantic field descriptions, middleware, background tasks, webhook handlers, and fallback prompts hidden in error branches. It then shows an 18-item checklist of places it may have missed and waits for an answer to each one. It drafts the constitution one section at a time for approval and never fills a field on its own.
Scenarios come in two modes. Curated proposes 8 to 12, reviewed one at a time. Bulk proposes 20 to 50, approved by category, for broad first coverage of a new agent. The target mix is about 25% golden path, 20% edge cases, 20% refusals, 15% tool use, 10% known failure modes and 10% odd input (empty, very long, emoji only, injection-shaped). Bulk scenarios carry their batch date, so the ones that always score 5 or never move can be pruned later. A clone-translate mode copies a profile into another language and flags refusal and tone scenarios for a cultural check.
runner.py loads a profile and a scenario and calls adapters/<endpoint>.py. The adapter's
whole contract is call(scenario, profile), returning the reply, tool calls, tokens and metadata. Results are
cached under a hash of the scenario, the combined prompt hash and the model config, so a re-run with nothing changed
is free and any prompt edit forces a fresh call.snapshot.py turns db_row, file and code_constant sources into text plus SHA-256.judge.py builds the grading message (constitution, scenario, reply, latency, tool calls) and calls Claude at
temperature 0 with the pinned judge prompt. Its cache key is the reply, the criteria, the judge model and the
judge-prompt version.judge_prompts/v1.md gives one 1–5 score over six factors: accuracy, helpfulness, speed, persona fit, no
invention, and forward motion. Hard caps apply first: a must-not violation or any invented fact caps the score at 2,
the wrong language caps it at 1, and a reply over 10 seconds caps it at 3. Every failure is tagged with its factor
(accuracy:, invention:, speed: …), so a report can show which kind of failure increased.
Sonia is the shop assistant behind FormAI, DBM AI's chat widget for online shops. Its first profile,
generalist-en, runs 15 bulk-generated scenarios against a demo WooCommerce shop with 51 products priced in
euros. It was run five times in one day.
Table 3. Five audit runs of Sonia, generalist-en. Mean score out of 5.
| Run | Mean | What changed | What the audit showed |
|---|---|---|---|
| R1 | 4.20 | Baseline | Prices without a currency in 6 of 15 scenarios; three product searches over 5 s |
| R2 | 4.33 | Currency rule added to the answer prompt; per-step timers added | The rule had no effect. Timings showed the answer model never saw the shop's currency; only the router did. The time went on the two model calls, not on WooCommerce. |
| R3 | 4.27 | Answer prompt cut from 3,348 to 1,977 characters; router moved to gpt-5.4-mini; currency tag passed to the answer model | Personality switching fell from 5 to 2 (the router passed bad parameters). Mean latency rose 24%. |
| R4 | 4.20 | Router moved back to gpt-4.1-mini | Personality switching back to 5. Latency varied run to run. |
| R5 | 4.47 | Code post-processor adds the shop's currency to bare prices | Currency on 15 of 15 prices, up from 0% in R1 and R2 |
Three things came out of it. The router change was meant to make Sonia faster; it made her slower and broke a tool, and without fixed scenarios it would have shipped. The prompt rule for currency was the wrong fix: the model it was written for could not see the data it referred to, and once it could, gpt-4.1-mini still followed it only some of the time, so a few lines of deterministic code replaced it. And one run is not enough to judge latency.
judge.py with its pinned Sonnet 4.6 at temperature 0. That session also proposed
the fixes, so the grader was not separate from the change, and the scores cannot be reproduced exactly. Future runs
should call judge.py as written.V2Prompt rows already fit the db_row snapshot kind.Both systems split the work the same way: the model does the language, and code you can test makes the decisions. In onboarding, that split is what lets a cheap, non-reasoning model run a goal-driven conversation: two narrow model calls per turn, plain Python for scoring, stopping and loop-breaking, and a parallel fan-out for the writing. The Sonia runs show the same move from the other side: when gpt-4.1-mini would not follow a prompt rule reliably, a few lines of code fixed what the prompt could not. In prompt-audit the split means fixed scenarios, hashed prompt snapshots and a pinned judge, so a prompt change shows up as a number before it reaches users.
Appendix. Where the code lives.
| Path | What it does |
|---|---|
onboarding/v2/ingredients.py | The nine field definitions, priorities and the threshold of 7 |
onboarding/v2/extractor.py, responder.py | The two model calls per turn |
onboarding/v2/evaluation.py | Merge, scoring of services and schedule, stop rule, next target |
onboarding/v2/views.py | Turn control: minimum turns, wrap-up at 9, anti-loop, skip |
onboarding/v2/generator.py | LangGraph writer graph and save |
onboarding/v2/management/commands/simulate_onboarding.py | LLM-simulated practitioners |
tests/prompt-audit/_runner/ | Runner, snapshot, judge, adapters, versioned judge prompts |
~/.claude/skills/prompt-audit-build, prompt-audit-run | The two Claude Code skills |