1 h 48 min
from the start of the training to a working chat that answers questions about a database in Estonian. The schedule allowed three hours. Claude Code was at the keyboard; I was listening to the trainer on Zoom.
If you won't read to the end
- Three hours of Java training, zero lines of Java by my hand. Claude Code wrote, I listened. Neither of us got a certificate.
- The model refused to delete the orders on its own. Good model. We trust it exactly as much as a web form someone just typed "'; DROP TABLE" into.
- The entire defence is eleven lines of Java that never call the AI: SELECT only, zero semicolons, eleven forbidden words.
- The trainer's step 6 lists four risks and three defences. We had one of three. Lesson from the trainer's solution: swap one exception class and the error is right.
- gemini-2.5-flash returns 404 for a fresh key. The model exists, just not for you.
- Bonus: I ask in Estonian and the app answers in my own voice. Ten products, four tables, one voice clone.
All the code, voice in and out included, is public: github.com/hv-tec/java-koolitus.
Three hours are not spent on code
I don't write Java. I signed up for the eesti.ai "Java + LLM" training anyway, because the promise was concrete: in three hours, a chat assistant that answers questions about a database in plain language. Four steps, a checkpoint after each: project running, Java talks to the model, a question becomes SQL, the query runs and the result is explained.
The trainer, Meelis Teern, did the steps live with OpenCode, a coding agent that runs in the terminal. At my keyboard was Claude Code: I listened and gave direction, it wrote the code. The checkpoints don't care which tool you use. All four steps were done before the schedule reached its last stage.
The code is not what takes three hours. What takes three hours is understanding why one function in that code matters more than all the LLM calls combined.
The model refused on its own. That is not enough
First test: "Delete all orders." Gemini returned CANNOT_ANSWER. The system prompt says only
SELECT is allowed, and the model did as told. The temptation is to stop here.
The trainer's architecture slide says otherwise: SQL comes out of the model, and before the database there is a separate box labelled "SQL validation". The principle fits in one sentence: the model's output is input. It is treated like text from a web form that nobody has checked.
A prompt is a request. The model honours it most of the time, and "most of the time" is not a
security boundary. One day a line comes back that starts with SELECT and continues with
something else after a semicolon. The only deterministic layer in this app is a function that
never calls the model at all.
- The statement must start with SELECT. Comments are stripped before the check.
- No semicolons. One question, one statement.
- Eleven forbidden words: INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, MERGE, TRUNCATE, GRANT, EXEC, CALL.
- The model refusing is not protection. It is the first layer, and nothing is built on top of it.
Three checks, eleven lines of Java. Honestly: during the training, validateSql never caught a
single query; the model always refused on its own. That is exactly why it is tempting to leave
the function out, and exactly why you must not.
Step 6 came when the app was already working
The trainer's last slide: "how a working solution can behave dangerously". Four risks and a minimum defence.
- DELETE or UPDATE — the risk of changing data.
- Non-existent table — the model hallucinates a schema that isn't there.
- Ambiguous question — the SQL is correct, the interpretation is wrong.
- Too broad a query — a slow or enormous result.
- Minimum defence: a SELECT-only check + an allowlist of tables and columns + a clear error message to the user.
Of the three defences, we had one at the end of the training. There is no table allowlist. And
when validateSql rejected a query, the user got a 502 "AI service unavailable": the service
worked, the check worked, only the message was wrong.
The trainer's own solution has it right: 400 "Only SELECT queries are allowed". The difference was one exception class; I fixed the same line. His solution, in turn, has no semicolon check; ours does. Neither has a table allowlist. That stayed the next step for both.
Two calls, not one
One question is two model calls. The first gets the schema and the question and returns SQL.
The second gets the question and the query result and returns an Estonian sentence with no
table names and no SQL. "Which customers have ordered the most?" produced a query with JOINs
across three tables and the answer "Mari Tamm and Jaan Kask, two orders each". A question about
the weather came back as CANNOT_ANSWER, because there is no weather in the schema.
The free tier is capped at 15 requests per minute. Two calls per question makes that 7 questions a minute. Enough for a training, not for ten users.
The trap that returns 404
The trainer's choice was gemini-3.5-flash-lite. The first attempt, out of habit, was
gemini-2.5-flash, and a fresh API key got a 404 with the text "no longer available to new
users". The same happens if you leave the -preview suffix on the model name, which the
documentation shows in some places. The model name is now an environment variable, not a
constant in the code.
Estonian in and out without a single line of Java
There was time left over between steps. Chrome's Web Speech API recognises et-EE without a
single line of Java: one HTML page, a microphone button, and the text goes to the same
/api/ask endpoint.
Reading the answer aloud took one extra Spring Boot controller. The text goes to Jutusta.ee speech synthesis using a clone of my own voice; the API key stays on the server and the browser only receives audio. I ask in Estonian "what is the most expensive product?" and hear "Laptop Pro 15" in my own voice. The data is ten products from the trainer's seed file, not a real shop. The mechanism is the same.
What I do differently now
Every AI output that travels onward to a place where it has consequences gets its own
validateSql. SQL to a database, an email to a customer, a command to a device. If the prompt
is the only place in your app where "SELECT only" is written down, you don't have protection,
you have a request.
What stayed open is what happens with real data. A four-table schema fits in the prompt whole. A hundred tables won't, and then you have to decide before the model call which part of the schema the model gets to see at all. That is the next experiment. The code is public: github.com/hv-tec/java-koolitus.