Case study
CCCApply Assistant
A chat assistant for CCCApply, California's community college application, that answers in 15 languages with clickable source footnotes.
Cited CCCApply answers in 15 languages, built in a one-week bootcamp
Who had the problem#
CCCApply is the online application for California's community colleges, and it once took me 4 Zoom calls to figure out. For a student who reads English as a second language, the application and the policy pages behind it are harder still.
We built it for 2 groups: students applying to or exploring California community colleges, and the staff who help them. Both need the same thing, a clear answer they can check against the real documentation.
What I built#
The CCCApply Assistant is a chat bubble in the corner of a web page. Pick one of 15 languages, ask a question, and it answers in that language with numbered footnotes that point to its sources. Click a footnote and the source page opens, scrolled to a phrase from the cited passage. Students can also drop in a screenshot or a PDF and ask about it.
My teammates wrote the first drafts (a Streamlit prototype, then a 131-line Flask chatbot that already turned citations into footnote links), the code that merges duplicate citations, an AWS Lambda and API Gateway deployment path, and a weekly job that re-syncs the Knowledge Base. I took the chatbot from there: the Flask backend, the chat window and sources panel, image and PDF uploads, conversation memory, the prompt, and multilingual support, with keyboard and screen-reader support in the widget.
How it works#
The widget sends the message, the chosen language and the recent chat to a Flask endpoint, which folds the last 4 messages (up to 300 characters each) into the query and translates it to English when needed. Bedrock's retrieve-and-generate then searches a Knowledge Base I built and chunked from CCCApply's public documentation, and writes the answer with Claude 3.5 Sonnet v2 at temperature 0.1. The backend turns Bedrock's citations into a numbered list of title, URL and snippet, and the widget turns every [n] in the answer into a clickable footnote. A clicked footnote opens the source with a text fragment (#:~:text=), so the browser jumps to the cited phrase instead of the top of a long page. The diagram maps that whole path, from the widget to Bedrock and back.
- The widget sends the message, the chosen language and the recent chat to a Flask endpoint.
- The endpoint folds the last 4 messages into the query and translates it to English when needed.
- Bedrock's retrieve-and-generate searches the Knowledge Base built from CCCApply's public documentation.
- Claude 3.5 Sonnet v2 writes the answer at temperature 0.1, under the prompt template.
- Images and PDFs take a separate path, straight to Claude's vision input.
- The backend turns Bedrock's citations into a numbered list of title, URL and snippet.
- The widget turns every [n] in the answer into a clickable footnote, and a clicked footnote opens the source page at the cited phrase.
The safety rules live in a prompt template I wrote, passed to Bedrock in place of the default one. Claude may answer only from the retrieved passages, must footnote every factual claim that has a working source URL, may cite at most 3 sources, and must not reveal internal IDs or retrieval details. When the passages don't cover the question, it must reply with exactly one sentence: "I couldn't find that in the documentation."
Files take a separate path. Images go straight to Claude's vision input on Bedrock, and PDFs are rendered with PyMuPDF at 2x (the first 10 pages at most), then sent in a single call so the student gets one answer instead of a page-by-page dump. Uploads stay in memory and are never written to disk, behind a 16 MB cap and a file-type allowlist.
The hardest part#
Translation and citations pull in opposite directions. The Knowledge Base holds English documentation, so a question in any other language is translated to English before retrieval, and the answer has to travel back into the student's language. Run a whole answer through a translator and the URLs and [n] markers that tie each claim to its source can come out altered, and then the footnotes stop working.
I solved it in layers. The prompt tells Claude to answer in the target language and leave URLs untouched. As a safety net, the backend checks the reply for 14 common English words, and if more than 3 of them show up, it runs a second translation pass that keeps the Sources list out of the translator. When there is no Sources list to split off, a regex cuts the text around every URL and [n] marker so only the words between them get translated. If a translation call fails, the code falls back to the original text, so the student still gets an answer instead of an error.
Results in numbers#
The repo went from first commit to multilingual answers in 4 days, August 5 to 8, 2025. It has 25 commits from 3 people, 14 of them mine, and git blame credits me with 4,294 of the 4,481 lines in the final Flask backend and widget (96%), including all 1,578 lines of the widget's JavaScript.
We demoed it at the bootcamp showcase, and it runs as a local demo: a Flask server with the widget on a sample page, answering in 15 languages from the picker and reading images and the first 10 pages of a PDF. There is no automated test suite. A [TEST_SOURCES] switch returns a fixed 3-source answer so the footnote wiring can be checked without calling Bedrock, and a TEST_MODE flag stubs the vision calls.
What's next#
An evaluation set comes before anything else: real CCCApply questions in several languages, each paired with the page that should be cited, scored on every change. Then I'd replace the English-word check and the unofficial googletrans library with Amazon Translate, and move the hard-coded Knowledge Base ID and region into configuration, since the Lambda path still points at a different Knowledge Base.
I'd also sanitize the Markdown the widget renders, because it builds HTML from model output, and put the assistant in front of the people who answer these questions every day: counselors and application staff. The goal is the help I needed back then, in a single chat.