Context: Dicamus is an AI self-help service, not therapy and not a crisis line. If you are in danger right now, please contact your local emergency number.
I'm the technical founder of Dicamus, a self-help service where a person talks to an AI specialist about what's bothering them (work, relationships, anxiety, money, sleep) in English or Russian. We're a small team from Kyrgyzstan, the servers are in Germany, and the repo has 441 commits in its first two weeks.
This post covers how it's built, what broke, and what an outside clinical psychologist found when she graded us. There are no user conversations in this post: every example is invented or comes from simulated test cases.
TL;DR
- One prompt is not a product. Every reply goes through five agents, and about 85k tokens are read per message.
- Safety comes in layers. A model-based risk check sits on top, with a deterministic regex underneath for when that model fails (about 1 turn in 120).
- Prompts state the rules, code enforces the ones that must never break. For example, a filter strips "I'm here for you" because a machine shouldn't claim presence.
- Long-term memory is a temporal knowledge graph (Graphiti + Neo4j). The plan was two databases; we shipped one.
- We pick inference providers by real cost (prompt-cache share), not list price.
- Our weakest spot, according to the supervisor, is referring people on to a live specialist. We fixed it and published the whole review.
The reply pipeline
Here is what happens when someone sends a message:
message ├─► risk classifier ──────────────┐ (last 6 messages + risk history from past sessions) ├─► "attentive reader" ───────────┤ (last 8 messages, in parallel) ├─► book retrieval ───────────────┤ (42,000+ passages from 82 books) ├─► memory graph ─────────────────┤ (facts about this person, time-aware) │ ▼ └──────────────────────────► specialist (or crisis mode) ──► code filters ──► reply │ background: write the turn into the graph The model is DeepSeek V4.1 Flash with reasoning at the highest level, so a reply takes tens of seconds, not milliseconds. We decided early that understanding the person matters more than speed. While the person waits, the UI shows real steps such as "Recalls past conversations" and "Rereads your message". It doesn't show a fake typing indicator.
The "attentive reader"
The most useful agent turned out to be the one that never talks to the user. The reader gets the last 8 messages and writes a short note for the specialist: what the person actually said, what they repeat, which numbers are ambiguous, and where the specialist might be jumping to conclusions.
LLMs love to diagnose on message two. So the specialist is told to think more like a doctor:
- keep 2–4 working hypotheses and ask the question that separates them;
- explain a cause only at 70%+ confidence;
- offer a plan only at 85%+, and only after the person confirms.
The reader runs in parallel, with a 90-second budget (40 seconds in crisis mode). If it's late, the specialist answers without the note, so a slow helper never blocks the reply.
Cross-lingual book retrieval
Most of the source books are in English, while many users write in Russian. We embed with Qwen3-Embedding using an instruction prefix (roughly "Given a Russian message from a person talking to {role}, retrieve the relevant passage") and store the vectors in Neo4j.
Retrieval sits on the critical path, so the embedding call is hedged. If the first provider hasn't answered within 0.8 s, the same request goes to a second provider in parallel.
Safety in layers
This is the part I lose sleep over.
Layer 1: a model decides the risk level
A classifier reads the last 6 messages and returns structured JSON with a risk level, a category, signals, and whether a direct question is needed.
Six messages are not enough on their own. The supervisor pointed out that sudden calm after a high-risk session is itself a warning sign, so the classifier also gets a short risk history from past sessions.
We don't use keyword matching as the primary check, because risk often has no keywords. "I wish I could just disappear" four months after giving birth means something different from the same words after a bad meeting. The classifier also flags combinations of factors: postpartum plus emptiness, self-harm in a teenager, fear of hurting a child, thoughts of death becoming more frequent. In those conversations the specialist must name a live specialist and how to reach one.
Layer 2: a regex for when the model fails
Models fail. They return broken JSON, time out, or answer without the required fields. We retry once. If the second attempt also fails (about 1 turn in 120), we don't fall back to "no risk". We fall back to a plain regex on the last user message:
RISK_NET = re.compile( r"kill myself|suicid|end my life|want to die|don'?t want to live|" r"better off dead|cut myself|hurt myself|" r"покончить с собой|самоубий|не хочу (больше )?жить|...", re.IGNORECASE) if classifier_failed and RISK_NET.search(last_user_message): risk = "medium" # crisis mode + a direct, gentle question turn["risk_net"] = True # logged, so we can see how often it fires It is crude on purpose. Its only job is to make sure a failed model call can never turn "I want to die" into a normal reply.
Layer 3: don't throw hotline numbers at people
The blind review taught us the most here. In one test case, the general-purpose model answered someone with passive suicidal thoughts and no plan by listing emergency numbers. The supervisor gave it 4/10: "That breaks the contact."
Our crisis protocol:
- ask directly and gently about thoughts of death, then about frequency, a plan, and means;
- if there is a plan, ask the person to put the means away, and give an emergency number in one sentence;
- keep talking.
The number depends on the language (112 for Russian, 911 for English).
An incident log with no text
Every crisis turn is marked with flags only:
- was the direct question asked;
- were means addressed;
- was a number given;
- was a live specialist named;
- did a code filter fire.
The supervisor reviews these monthly, and only for people who agreed to conversation review. We can measure "did we do the mandatory steps" without storing anyone's words in the log.
Prompts state rules, code enforces them
Every prompt said "don't imitate feelings or presence". The model still wrote "I'm here", "I heard you", "Stay with me". The supervisor's note: "A machine shouldn't imitate human presence."
So there's now a code filter after generation. It removes the clause, or drops the whole sentence when the sentence is nothing but that clause:
_PRESENCE = re.compile(r"stay\s+with\s+me|I\s+(?:hear|heard)\s+(?:that|you)(?=\s*[.!]|\s*$)|...", re.I) reply, removed = drop_presence(reply) # count is logged per turn We use the same pattern elsewhere. If the specialist returns broken JSON, we extract the reply field with a regex rather than lose the answer. If there's no usable answer at all, we send a pre-written fallback. Each case is logged.
The rule of thumb: a prompt is a request, and code is a guarantee. Anything that must never happen needs both.
Memory: a temporal knowledge graph
A self-help conversation lasts weeks, not minutes. "I hate my job" in week one and "I quit" in week three are both true, at different times. A plain vector store returns both, and the model mixes them.
We use Graphiti on Neo4j, where facts carry validity in time:
- after each reply, the turn is written into the graph by a background worker, so the user never waits for it;
- before a reply, we search facts relevant to the message, combining keywords, semantics and recency, and have a small fast model pick the relevant ones;
- outdated facts are shown as "previously: …", ranked below current ones;
- if Neo4j is down, the bot answers from plain notes in SQLite.
We measured the ranking on 16 questions across 4 sessions. After the change:
- the needed fact landed in the top 3 in 15 of 15 cases, up from 11;
- its average position went from 2.8 to 1.2;
- outdated facts ranked above current ones in 0 cases, down from 4.
The architecture lesson. The original design was Neo4j for the graph plus Qdrant for vectors. We shipped with Neo4j alone, because it already had vector and full-text indexes. One fewer database to back up, secure and keep consistent.
Privacy inside memory. Each specialist has separate memory. The work specialist doesn't know what you told the relationships specialist. Context passes to a colleague only after the person explicitly agrees ("Yes, pass it on").
Picking inference providers by real cost
We route DeepSeek through OpenRouter, where several providers serve the same model. List price turned out to be misleading in two ways.
- The cache share decides the bill. Our specialist prompt is about 20k tokens. One provider reused about 80% of it from cache, another about 60%. At the same per-token price, the second was roughly twice as expensive per reply.
- Speed on a short probe lies. One provider was fast on a ping-sized request and 3–5× slower than the others on a real 20k-token prompt. Some providers advertised reasoning and answered without it.
So a watchdog picks providers from live data. It requires:
- fp8 or better precision;
- at least 99% uptime over the last day and the last 30 minutes;
- reasoning that actually happens, checked with two small probes;
- an acceptable latency on a real prompt.
It then sorts the remaining providers by effective price, using each provider's cache share measured on live traffic over two weeks. Live latency from the last 30 minutes beats the daily probe, so a slow provider is dropped within minutes. As a policy decision we also excluded providers based in China; processing happens in the US and Europe.
Testing: about 600 scripted conversations
Every change runs against scripted conversations in English and Russian. They cover:
- crises;
- traps, such as "just give me a productivity hack" from someone drinking every night;
- referrals and memory;
- corrections ("no, that's not what I meant").
Every crisis test must pass.
What it costs. LLM-as-judge is expensive at this scale: a judge pass on 16 tests cost us $0.71. Day-to-day runs skip the judge, and we read the answers ourselves.
The external review
Once a month a clinical psychologist (our supervisor) reviews quality.
Blind check. We ran two hard simulated cases through Dicamus and through Claude Sonnet 5.5. The person was played by a model from a written profile. She and a practising colleague saw the transcripts under letters, without knowing which was ours.
| Case 1: passive suicidal thoughts, long job search | Case 2: masked burnout, wine every evening | |
|---|---|---|
| Dicamus | 9 | 10 |
| Claude Sonnet 5.5, plain chat | 4 | 3 |
| Claude Sonnet 5.5, "imagine you're a psychologist" | 2 | 4 |
To be fair about the method: these are two cases, judged by two people. A general-purpose chat isn't built for this job, so that comparison is easy to win. We also compared against other general chats, but that check wasn't fully blind, so we don't publish it.
Unseen cases. The more useful check was the second one: 12 new hard cases she had never seen, run on the live version with no tuning. We averaged 8.5/10. In 3 of the 12, the assistant gave good support but didn't refer the person to a live specialist when it should have. Her summary: "good at being 'with' the person, weaker at 'passing them on'." That is what led to the factor-combination flags described above.
She also read all our prompts and found 22 contradictions between rules. The fix was a hierarchy: safety rules come last in every turn's instructions and override everything above them.
Everything is published, including the mistakes: dicamus.com/en/quality.
What we got wrong
- An assistant nobody needed. We had an always-on helper between sessions. In practice it confused people about who they were talking to and what they were paying for. It's hidden now. The code is still there, behind a flag.
- Pricing, three times. We went from session credits to a subscription. Then we made unused sessions expire, reverted that the same day, and finally settled on "a subscription for a term, one session every N days". Each version looked logical on paper; the reverts are in the git history.
- Asking for login too early. Guests got one specialist reply before being asked to sign in. One reply isn't enough to tell whether this helps you, so now guests get five before we ask for anything.
- Analytics silently dropping ad traffic. Instagram appends a long
fbclidto the referrer, and our validation rejected it with a 422. The visits worked for users but never reached our funnel stats. Now we truncate instead of rejecting. - "Psychologist" is a protected title in the US. The English site now says "AI specialist" everywhere. The Russian site keeps "ИИ-психолог", which is common usage there.
- The default language told a story we didn't intend. The root domain served Russian, so search engines and AI assistants described us as "a Russian company". Now the root is English for everyone, CIS languages are redirected to /ru, and
llms.txtopens with who we actually are.
Privacy, briefly
- Field-level encryption. Personal texts in SQLite (messages, notes, session summaries, crisis signals) are encrypted with AES-256-GCM, with a random nonce per value.
- The key lives elsewhere. It's derived with scrypt from a passphrase that isn't in the database, the backups or git. A leaked database file is unreadable.
- The server refuses a wrong key. A check value in the database stops the server from starting with the wrong key, instead of silently writing data with a new one.
- Backups. Nightly encrypted backups cover the database, the graph and the secrets. Restoring to a new server is one script.
Built with an AI pair
Most of the code was written together with Claude Code. I made the architecture and product decisions and reviewed everything; the agent wrote and refactored at a pace a small team couldn't match otherwise. The commit history makes this visible, and I think that's the honest way to say it.
If you work on LLM safety, mental-health tech, or evaluation, I'd really value your critique. What would you test first? Comments are open, and the quality log has the full supervisor review.