Back

ACTO

The routing layer for a health system’s patient support line: answer the repeats straight from cache, and send everything else to the agent or model that should handle it.

May 2025 – Aug 2025 · Software Engineer Intern · AI infrastructure

misclassification, across prompt variants
28% → 15%
cache threshold, measured rather than guessed
~0.92
endpoints moved onto Form Requests
12+
of manual QA no longer needed
5 hrs/wk

The problem

The same questions, rephrased a thousand ways, all sent to one expensive model.

The client runs the patient support line for a health system. Most of what came in was the same questions rephrased a thousand ways — booking, coverage, visit prep, lab results — and every one went to a at full price.

Paying the top rate was only half the problem. The other half was that one general model is the wrong place for most of these questions. Some need something done, like moving an appointment, which is a job for something with access to the booking system. Others need an answer from the client’s own medical documents, which is a job for a model that has read them.

That second kind is where the stakes are. A health question has to reach a model that answers from the client’s documents. Sending it somewhere with no medical knowledge is the worst thing this system can do, and most of the design is arranged around not doing it.

The system

Answer the repeats from cache, classify the rest, and send each to the right destination.

Our team built ACTO to sit in front of that traffic. It answers repeats out of without calling a model, classifies whatever’s left, and sends it to one of two kinds of destination: an admin with tools that act on the client’s site, for booking, coverage and visit prep; or one of the models on our platform, each backed by the the client chose — Opus, GPT, whatever they prefer — and answering from the client’s own documents.

Questionfrom a patientEmbedin the VPCCache checkcosine ≥ 0.92Answer+ label, destination, mshitmiss · same vectorClassifybar + marginRouteper client configsureunsureLLM classifiertemperature 0Admin agentsite toolsRAG modelclient docsteal is the routing layer I owned · the grey destinations were built by my teammate
Every query is embedded once. A hit returns straight away; a miss is classified by embeddings when they’re sure and by an LLM when they aren’t.

Clients set up the RAG side themselves, through a frontend service we built. They pick the LLM behind each model, upload documents that get indexed automatically, add example questions so the router knows what belongs there, and can remove any of it. That last part matters for everything below: the client’s example questions are what the router matches against.

My part: I owned the routing side — the embedding, the cache, the classifier, the routing, and the eval harness. My teammate built the admin agent and the RAG pipelines. Every response comes back with the answer plus the label, the destination, whether it came from cache, whether the LLM classified it, and how long it took.

The routing layer

One embedding

Made inside the network, and reused by everything after it.

The query goes through bge-large-en-v1.5, which turns text into an : 1,024 numbers standing in for what the text means, so two ways of asking the same thing land near each other. It’s self-hosted inside the , because patient queries carry and that content isn’t leaving the private network to be embedded by somebody else’s API.

One gets made per query, and it gets reused three times: by the cache check, by the classifier, and overnight by the job that clusters the day’s log without re-embedding anything.

The semantic cache

Match on meaning, set the bar high, and never cache anything personal.

The cache holds a small, curated set of question-and-answer pairs. A new query is compared against them by , and above about 0.92 the stored answer comes back without a model being called at all.

What makes it semantic is that the embedding places text by meaning rather than wording, so two questions that mean the same thing land close together even if they share no words. “How do I cancel?” and “cancel my appointment” score high. “How do I cancel?” and “how do I book?” share a word and score lower. Nobody decides by hand what counts as the same question; the does.

the same intent, as people actually type itexactcosine
how do I cancel my appointment?hit1.00 hit
need to cancel my appointmentmiss0.96 hit
can I call off Thursday's visit?miss0.93 hit
cancelling the appt I bookedmiss0.93 hit
how do I reschedule my appointment?miss0.88 miss
how do I book an appointment?miss0.74 miss

One of six on exact strings, four of six on meaning. The last two are why the bar is high: reschedule is close wording with a different answer, and book shares a word but means something else entirely.

Illustrative similarities for one intent. Exact-string matching catches only the phrasing it already has.

How we got 0.92: empirically. We built two sets of pairs: paraphrases that should hit, same question in different words, and near-misses that must not, similar wording with a different answer, like cancel versus reschedule. We scored every pair, looked at where the two groups landed, and picked the value that kept the near-misses below it.

It’s high on purpose, because the two mistakes don’t cost the same. A miss costs one model call. A false hit serves someone a confidently wrong answer. We’d rather pay.

cached question · how do I cancel my appointment?

0.96need to cancel my appointmentfrom cache
0.95want to cancel the appt I booked onlinefrom cache
0.93can I call off Thursday's visit?from cache
0.93cancelling the appt I madefrom cache
0.91trying to cancel, how?calls a model
0.88how do I reschedule my appointment?calls a model
0.84how do I cancel my payment plan?calls a model
0.81how do I cancel my insurance?calls a model

nothing user-specific or action-based is cached at any threshold, so a real booking never comes from here

0.924/5 served from cache0 wrong answers

Every miss here costs one model call. That is the cheap side of the trade.

Drag the threshold. Illustrative scores, real trade: coming down serves more paraphrases from cache until questions that only look similar start getting the wrong answer.

Keeping the set small and curated does the other half of the work: a hit returns a vetted answer, not an arbitrary model generation from three weeks ago. And nothing user-specific or action-based ever lives there. A booking can’t come from cache, because it isn’t an answer, it’s an action on someone’s record.

One number that doesn’t travel: 0.92 belongs to this embedding model. Every model spreads meaning differently, and when I rebuilt the system on MiniLM, paraphrases landed near 0.75, so 0.92 would have missed almost everything. Swap the model, redo the pairs.

Classifying a miss

Embeddings settle the clear cases for free. An LLM takes the rest.

On a miss, the same vector is compared against the client’s example questions — one matrix multiply, no model call — and the best-matching example proposes a label. The only takes it if two things hold: the top match clears a high , and it clearly beats the second-best label by a . A high match that’s nearly tied with another label isn’t trusted, and neither is a query that doesn’t look much like any example.

Anything that fails either check goes to an LLM classifier. It gets a with the query, a description of each destination, and example questions for each, and picks one at 0.

“can I move my appointment to Friday?”

closest example question for each label

booking0.84
visit prep0.52
coverage0.44
after-visit care0.31

the white line is the acceptance bar

query

✓top match clears the bar (0.75)0.84

✓beats the runner-up by 0.10+margin 0.32

Embeddings decide. No model call: booking → admin agent.

Illustrative scores, labels and thresholds; the rule is the real one. Pick a query to see which check it passes, and who ends up deciding.

That split is the design. The embeddings settle the clear cases for free, and the LLM only runs on the uncertain slice. Both thresholds, the acceptance bar and the margin, were picked the same way as the cache’s: from measured pairs, not by feel.

Routing

A table the client edits, not code we deploy.

The label maps to a destination through the client’s config. Admin labels go to the agent; health labels go to the RAG model configured for that topic. When a client adds or removes a model in the frontend, routing picks up the change without a deploy.

labelgoes towhich answers with
bookingadmin agenttools that act on the client's site
coverageadmin agenttools that act on the client's site
visit prepadmin agenttools that act on the client's site
lab resultsRAG modelthe client's docs + the LLM they picked
medicationsRAG modelthe client's docs + the LLM they picked
after-visit careRAG modelthe client's docs + the LLM they picked

config, not code · a client adding a RAG model in the frontend adds its label here, and routing picks it up without a deploy.

the worst misroute · any health label landing on the admin agent, which has tools but no medical knowledge.

Illustrative labels. The admin side matches what the agent handles; the health side is whatever RAG models the client has set up.

Once the destination answers, the query, its vector and the answer are appended to the day’s log, and that’s all. Nothing on the request path writes to the cache.

The nightly harvest

Promotion happens offline, in bulk, on a day of evidence.

Overnight, a job the day’s log using the vectors already stored with each query, so nothing is re-embedded. Counting exact strings would undercount the paraphrases, and paraphrases are the whole point: twenty ways of asking about parking are one intent worth caching, not twenty one-offs. The largest groups get promoted into the cache, and the previous night’s harvest is flushed. The hand-curated entries are never touched.

Day's logquery, vector, answerClusterstored vectors“how do I cancel?”×214“where are my results?”×168“is parking free?”×96one-off phrasings×3illustrative counts; the top N groups get promotedtop NThe cachecurated pairshand-written and vetted · never touchedlast night's harvestreplaced wholesale on the next runthe request path never writes here
Promotion is a bulk decision made on a day of evidence, not a side effect of one request.

The clustering threshold is 0.40, and it isn’t a guess either. Queries within one intent scored 0.49 and above, queries from different intents scored 0.29 and below, and nothing landed between. With that clean, the cut goes in the gap.

0.000.250.500.751.00different intents ≤ 0.29same intent ≥ 0.49cut at 0.40
Measured separation, with the cut placed in the gap rather than picked by feel.
Proving it

The eval harness

The full classifier, on a frozen set, without touching anything live.

The harness runs a frozen set through the full classifier: embeddings, acceptance bar, margin and LLM classifier. It never touches the endpoint, Redis or the destinations. The embedding stage is free and , and only the uncertain queries call the LLM, at temperature 0, so a run is cheap and close to repeatable. That’s what let it run on every change.

The test set was generated with an LLM and approved by the client, so every label was signed off by someone who knows the domain. Each query carries its correct label, so grading is automatic: compare the predicted label with the stored one.

A variant is a different version of the prompt the LLM classifier gets, and that prompt has two parts that matter: a description of each destination, what the admin agent and each RAG model handles, and example questions for each. Everything else stays fixed — the embedding stage, the thresholds, and the test set, with its queries embedded once and reused — so any change in the score comes from the prompt alone. And each variant changes one thing, so it’s clear what moved it.

Test setclient-approvedEmbed oncevectors reusedEmbeddingsbar + marginsureunsurePrompt variantone change at a timeLLM classifiertemperature 0Gradevs the stored labelaccuracy · precision and recall per labelthe most frequent confusion pairsnever called:— the endpoint— Redis— the destinations
The prompt is the only thing that changes between runs.

The output is overall accuracy, per-label and , and the most frequent . The confusion pairs are what drive the next prompt change.

medications → coverage11
lab results → visit prep7
visit prep → booking4
after-visit care → medications2

One accuracy number says something is wrong. “Medications keeps getting labelled coverage” says the prompt isn’t separating those two clearly enough.

Illustrative counts. A ranked list of what gets mixed up is a to-do list; an accuracy number isn’t.

Prompt by prompt

Two things improved over the iterations: the examples, and the descriptions.

The examples. Variant A used only the client’s real queries, and some labels were too thin to cover how people actually phrase things. Variant B added LLM-generated queries modelled on the client’s. What worked best was the client’s real queries plus general health questions from the field, which covered phrasings the client’s own data didn’t.

The destination descriptions. Early prompts described each destination in a line or two, so when two sounded similar, the LLM had little to go on. We rewrote them to say what each one covers, what it doesn’t, and how it differs from its closest neighbour. That gave the LLM a real reason to pick one over the other, and it went straight at the top confusion pairs.

coverage → admin agent

before

Insurance and coverage questions.

after

covers ·
whether a plan pays for a visit, test or prescription; referrals; prior authorisation
doesn't cover ·
what a medication does, dosing, side effects
vs its neighbour ·
coverage is about who pays, not about the drug

medications → RAG model

before

Questions about medications.

after

covers ·
what a medication is for, dosing, side effects, interactions, refills
doesn't cover ·
whether insurance pays for it
vs its neighbour ·
medications is about the drug, not about the bill
Illustrative wording, real structure: the two destinations the classifier mixed up most, described before and after.

The loop was the same throughout: run the eval, find the worst confusion pair, change one thing in the prompt, and keep it only if the score held. Misclassification went from 28% to 15%.

the examples

  1. prompt A

    the client's real queries

    some labels too thin to cover how people phrase things

  2. prompt B

    + LLM-generated queries modelled on the client's

    aimed at the thin labels

  3. kept

    real queries + general health questions from the field

    covered phrasings the client's data didn't

the descriptions

  1. early

    a line or two per destination

    similar destinations looked interchangeable

  2. kept

    what each covers, what it doesn't, and how it differs from its closest neighbour

    aimed straight at the top confusion pairs

misclassification28%→15%one change per variant, kept only if the score held
Each track changed one thing at a time, so every step has a clear cause.

The headline number

Not accuracy, because not every mistake costs the same.

The score the harness leads with weights recall on health labels above everything else. A health question sent to the admin agent gets an answer from something with no medical knowledge. That’s a safety failure, not a cost one, and it doesn’t belong on the same scale as a booking question landing in coverage.

accuracyhealth recallweighted scoreverdict
prompt X86%higher93%0.71reject
prompt Y84%99%0.88ship

Illustrative numbers, real rule: Y is two points worse on accuracy and sends fewer health questions to the wrong place, so Y ships. Plain accuracy would have picked X.

Illustrative numbers, real rule. Plain accuracy would have shipped the prompt that misroutes more health questions.

So a variant can come out slightly worse on raw accuracy and still be the right one to ship, and the weighted score says so where accuracy alone wouldn’t.

What the harness doesn’t measure is answer quality. It checks whether the label was right, not whether the destination answered well. That needs its own set.

The validation layer

How a request moves

Route, controller, validation, response — and where it was going wrong.

ACTO’s main website is built on , and every request moves through the same four steps. A route maps a URL and method, like POST /appointments, to a method. The controller reads the input, calls the business logic, and returns a response; it’s meant to be a thin traffic director, not where the logic lives. Validation checks the input is what you expect before anything uses it. And the response is either the result or an error saying what was wrong.

The twelve-plus endpoints that take user input were the ones that mattered:

POST/appointmentsbook an appointment
PATCH/appointments/{id}reschedule or cancel
POST/patientscreate a patient profile
PATCH/patients/{id}update contact or insurance info
POST/chat/sessionsstart a conversation
POST/chat/messagessend a query into the routing layer
POST/feedbackrate a response
and a few more on the patient side
Seven of the twelve-plus. The chat message endpoint is where a patient’s question enters the routing layer above.

Before, each controller method validated its own input at the top. The same rule, like “email must be valid”, was written in several places, and each endpoint returned errors in its own shape. The frontend had to handle failures differently per endpoint, and QA had to recheck bad-input handling on every one.

Form Requests

Validation moved in front of the controller, where the framework enforces it.

A is a class Laravel gives you for the validation step. The rules, and optionally who’s allowed to make the request, go in that class, which is then type-hinted in the controller method. Laravel runs it automatically before the controller code, and if the input is bad, it sends back an error response on its own. The controller never sees bad input.

beforePOST /appointmentschecks{ error: … }PATCH /patients/{id}checks{ message: … }POST /feedbackchecks{ errors: [ … ] }each checks its own input, fails its own wayafterForm Requestrules + authfails422errors per fieldpassesControlleronly the workResponseone class per endpoint · the same 422 everywhere
Before, each controller checked its own input and failed in its own shape. After, the Form Request runs first and every failure is the same 422.

After, a request hits an endpoint and Laravel runs that endpoint’s Form Request first. If validation fails, it returns a standard with the errors listed per field, and the request never reaches the controller. If it passes, the controller gets input that’s already been validated and only handles the actual work.

Three things came out of that. Checks happen up front, so controllers stay thin and focused. Each endpoint’s rules live in one named class that’s easy to find and review. And the errors are consistent by default — enforced by Laravel itself, not by a team convention someone can forget.

The result: the frontend handles every failure the same way, and QA reviews the rules in the Form Request classes and trusts the error behaviour, instead of poking at every endpoint by hand. That’s the five hours a week.

Wrapping up

Where it’s weak

The parts I’d flag before anyone else does.

In the routing. The thresholds are global, when some labels should be much harder to hit than others. The nightly clustering is greedy and order-dependent, so the same log in a different order groups slightly differently. Confidence is a margin, not a probability. New labels are only as good as the client’s example questions. And nothing learns from a user rejecting an answer.

In the eval. The test phrasing is cleaner than real traffic. Temperature 0 is near-deterministic, not guaranteed. Prompt changes only reach the uncertain slice, so if the embeddings confidently pick the wrong label, no prompt fixes it. And when a client adds a model, its description and examples need to be good, and it needs test queries too, or the eval won’t notice it’s broken.

In the validation. More files to maintain, and rules shared across endpoints can still end up duplicated between Form Requests unless they’re pulled into shared rule classes.

What I took away

Work out which mistake is expensive before tuning anything. A miss against a false hit, a cost failure against a safety failure. Once those are written down, the thresholds and the scoring weights follow from them instead of from taste.

Spend the expensive thing only where it’s needed. The embeddings do the clear cases for free, and the LLM only sees the uncertain ones. The same idea runs through the whole layer: one vector, made once, reused three times.

A measurement cheap enough to run on every change gets run. Keeping the harness off the live system and pinned at temperature 0 mattered more than making it exhaustive, because it meant it ran on every change rather than before releases.

Scope is worth stating plainly. Our team built ACTO end to end. I owned the layer that decides where each query goes, and saying that reads better than implying I built the whole thing.

Stack

Embeddingsbge-large-en-v1.5, 1,024 dimensions, MIT licence, self-hosted in the VPC
CacheRedis, cosine match against a small curated set
ClassifierEmbedding match against the client’s example questions, LLM fallback at temperature 0
DestinationsAn admin agent, and RAG models on the LLMs each client picks (built by my teammate)
Website and APILaravel, PHP, Form Requests
Offline jobsPython — nightly clustering and the eval harness
StorageMySQL
ObservabilityDatadog — 5 dashboards and 5+ alerts on routing latency and classification errors