Project notes
A Vietnamese Covid chatbot, built the pre-LLM way
A team project from 2021 to 2023: a Vietnamese chatbot for Covid-19 patients built from an intent classifier, regex extraction, a stored conversation state and hand-written replies.
In late 2021 I joined a team building a chatbot that could talk to Covid-19 patients in Vietnamese. There was no LLM to lean on, so every piece of understanding was something we had to build: classify the message, pull out the facts, remember the conversation and write the reply. This is how that went, and what I'd do differently.
- Load prior conversation state
- Extract intent and information
- Choose a dialogue action
- Generate a structured reply
- Persist the updated conversation
- Return text and response options
What we were trying to build
The project ran from September 2021 to March 2023 and several of us committed to it, so "we" below means the team. I'm not going to split up who wrote what.
The bot only had to do four things: ask a few questions when someone mentions symptoms, answer general questions about the disease, give contact details for local health stations, and report current case numbers. Anything else got a default question back. Splitting the backend into understanding, dialogue and reply generation kept those four jobs from tangling together, and it meant that when an answer was wrong we could tell which stage was to blame.
Vietnamese chat is messy
Real messages show up in lowercase with no diacritics, with Telex typing slips, with abbreviations and slang. Nothing downstream works on that, so the first thing a message meets is a clean-up step: normalise the Unicode, expand abbreviations from dictionary files, repair Telex mistakes and turn shorthand like Q1 into a district name.
The dictionaries live in plain data files, so adding a new bit of slang doesn't mean touching code. The corrector also has two further steps, one for near-key typos and one for putting diacritics back, and both are commented out in the repository. I never measured whether the correction that does run improves classification, so I can't say how much it helped.
A classifier proposes, rules overrule
Intent came from pickled TF-IDF plus classifier pairs: one for the broad kind of message (greeting, request, information, acknowledgement) and one for what a request was about. The retraining code does a randomised search over an SVC and builds a nearest-neighbour model, and training examples sit in MongoDB so new ones can be added through an API.
The classifier's label isn't final. If the text contains a question word, it becomes a request; if the extractor found an age or a district, it becomes information. Those overrides are in the snippet below. I don't know why they were added; the repository doesn't say.
Entity extraction is regular expressions and keyword lists: age, sex, district, and two lists of symptoms, ordinary and serious. It's crude, and a rephrased symptom just gets missed until someone adds a pattern. For a small fixed set of things to look for, it's quick to write and easy to debug.
intent = Config.model_intent.predict(Config.tfidf_intent.transform([message]))[0].lower()
sub_intent = Config.model_svm.predict(Config.tfidf_svm.transform([message]))[0].lower()
if intent in ['hello', 'other']:
pass
elif intent == 'request' or any(re.search(w_ques[i], message) for i in w_ques):
intent = 'request_' + sub_intent
elif intent == 'inform' or any(entity_dict[i] for i in entity_dict):
intent = 'inform'
else:
intent = 'ok'Remembering what the user already said
Every turn is stored in MongoDB with the sender, the text, the intent, the dialogue result and the reply. On the next message the bot loads that sender's turns, takes the last one and starts from its action code and slots. That's what lets a bare "yes" be read as the answer to the question just asked.
It loads every stored turn and uses only the last. That's fine for a demo and wasteful for a long chat; I'd query for the latest turn only.
The frontend is a separate Botkit service. It waits about a second after the last message before calling the backend, splits a reply on an asterisk into separate bubbles, and shows the answer options as quick-reply buttons.
Symptom questions are a fixed tree
When someone mentions symptoms, the bot walks a short tree: have you been tested, with what kind of test and what result, have you been in an affected area, have you had close contact. It's a nested dictionary in diagnose_reply.py and a counter in the slots tracks where the user is. The outcome is one of three codes, serious, high or low, and the reply text tells people the result may be inaccurate and to contact a health centre.
Nobody clinical reviewed that tree as far as I know, and I wouldn't present it as medical advice.
General questions: retrieve, and ask when unsure
General questions aren't classified; they're looked up. The message is vectorised with TF-IDF and compared with a stored list of questions. If the closest one is more than 0.85 similar, its stored answer goes back. If not, the bot offers the two closest questions as buttons and asks which one the user meant.
I still like that. For a health topic, one extra turn costs less than a confident wrong answer. I don't know how the 0.85 was picked. The question list on the refactor branch has 583 entries, and I never re-checked those answers against current guidance.
What I'd change
The part I'd fix first is that we never built a test set. The code prints a training score and a test score, but there are no stored results, label counts or confusion matrix, so I have no accuracy number to give you. A labelled set for intents and for the retrieval threshold would come before any tuning, with the scores kept next to the models.
I'd keep the stage boundaries and the ask-when-unsure habit. And I'd compare the regex extraction against a trained extractor instead of assuming rules were enough.
One practical note: the main branch won't run from a clean checkout, because the models and data are git-ignored or tracked in DVC, and I no longer have the DVC remote. Other branches commit the pickles. I wrote this from reading the code, not from a fresh run.
Takeaways
- Separate understanding, dialogue and reply generation so a wrong answer can be traced to one stage.
- Ask the user to pick between close matches instead of answering confidently and wrongly.
- Rule-based extraction is simple to debug but only as good as its pattern list.
- Build the labelled test set before tuning anything.
References
- covid-consultant-chatbot on GitHub · the code this post describes
- PhoBERT · background only; not found in the repository
- scikit-learn: nearest neighbors
- Botkit · chat framework used by the frontend