An embeddable web widget that answers frequently asked questions about the Examen Diagnóstico de Matemática (Math Diagnostic Exam) at Tecnológico de Costa Rica.
The project builds a native Web Component — no React, no backend, no
database required. The final bundle lands in chatbot/ and can be copied
into any other site.
Each user message goes through a small pipeline before falling back to search:
- Rule-based intent detection — greetings, thanks, farewells, and guardrails around academic-integrity questions (e.g. "give me the exam answers") or platform-security questions are matched against fixed rules. Score-outcome questions ("I got an 85") are resolved with hardcoded thresholds (e.g. ≥80 → eligible for the placement exam, <60 → recommended for the support workshop).
- Date-aware answers — "how many days until X" is computed live from
meta.schedule. - Fuzzy search fallback — anything else is matched against the knowledge base with Fuse.js and classified as high/medium/low/ambiguous confidence. High/medium confidence shows the stored answer; ambiguous asks a clarifying question; low confidence falls back to a "contact us" message. The bot never invents an answer.
There is no generative AI/LLM involved anywhere in the widget.
Running the build produces:
chatbot/
├── README.txt
├── tec-math-chatbot.es.js
├── tec-math-chatbot.iife.js
└── data/
└── knowledge.jsonTo embed that folder in a page:
<script type="module" src="./chatbot/tec-math-chatbot.es.js"></script>
<tec-math-chatbot
data-source="./chatbot/data/knowledge.json"
contact-email="diagnosticomate@tec.ac.cr">
</tec-math-chatbot>The chatbot/ folder must sit next to the HTML if using those relative
paths; adjust src and data-source otherwise.
- TypeScript, built with Vite in library mode (ES module + IIFE bundles)
- Native Web Component with Shadow DOM, no UI framework
- Fuse.js for fuzzy search — the only runtime dependency
- Vitest for unit tests, colocated with the source
- An optional, standalone Node.js
httpserver (analytics-endpoint/) as a reference backend for anonymized usage analytics — not required for the widget to work
npm install
npm run dev
npm run build
npm run edit:knowledge
npm run testnpm run dev— applies schedule dates, validates the knowledge JSON, buildschatbot/, and starts Vite to try the widget locally.npm run build— same validation/build, without starting Vite; produceschatbot/for delivery to another system.npm run edit:knowledge— interactive CLI to update dates or add topics.npm run test— runs the test suite.
npm run validate:knowledge and npm run apply:schedule exist as
standalone diagnostic commands, but dev/build already run what they need.
- Edit
public/data/knowledge.json. - Run
npm run dev(appliesmeta.schedule, validates the JSON, rebuildschatbot/, and starts Vite). - Open
http://localhost:5173.
npm run edit:knowledge
npm run devUseful for people who don't want to hand-edit JSON.
npm run build(optionally preceded by npm run edit:knowledge), then copy the generated
chatbot/ folder.
The main editable source is public/data/knowledge.json, with two
important sections:
meta— general config, contact email, and schedule dates.items— the answers the bot can find and show.
Dates live in meta.schedule for quick editing. Date-dependent items store
generic placeholder text to avoid visual contradictions; at load time, the
widget's JavaScript generates the real answer text from meta.schedule.
The simplest way:
npm run edit:knowledgeChoose "Actualizar fechas principales." The assistant only asks for dates in
YYYY-MM-DD format and generates display strings automatically (e.g.
"1 al 3 de diciembre de 2026").
You can also edit meta.schedule directly at the top of
public/data/knowledge.json:
"schedule": {
"inscription": {
"label": "1 al 3 de diciembre de 2026",
"shortLabel": "1 al 3 dic 2026",
"targetDate": "2026-12-01",
"expiresAt": "2026-12-03"
}
}After a manual edit, just run npm run dev or npm run build. There's no
need to hand-update the matricula, practica, aplicacion, resultados,
or fechas-importantes answers — those stay generic in the file, and the
widget generates the real text in memory at load time.
Via the assistant:
npm run edit:knowledgeChoose "Agregar tema nuevo" and answer the prompts (category, title, questions, keywords, semantic tags, answer).
Or edit public/data/knowledge.json directly, appending to items:
{
"id": "tema-nuevo",
"category": "Categoría visible",
"title": "Título interno del tema",
"questions": [
"¿Pregunta común 1?",
"¿Pregunta común 2?"
],
"keywords": ["palabra clave", "sinónimo"],
"semanticTags": ["intención del usuario", "frase parecida"],
"answer": "Respuesta breve y clara que mostrará el chatbot.",
"lastUpdated": "2026-06-01"
}Fields: id (unique, lowercase, hyphenated), category (visible group,
used for stats), title (short internal name), questions (real phrasings
a person would use), keywords (search terms), semanticTags (synonyms /
equivalent intents), answer, lastUpdated (YYYY-MM-DD), and the
optional targetDate / expiresAt for date-dependent topics.
Then run npm run dev or npm run build.
Analytics are not collected client-side by default. If the host site wants to track usage, it must expose its own endpoint and pass it to the widget:
<tec-math-chatbot
data-source="./chatbot/data/knowledge.json"
contact-email="diagnosticomate@tec.ac.cr"
analytics-endpoint="/api/tec-chatbot">
</tec-math-chatbot>If analytics-endpoint isn't set, nothing is sent. Recommended in
production: use a same-domain path (e.g. /api/tec-chatbot) rather than an
external URL, to avoid CORS and reduce the odds of ad-blockers filtering the
request — also avoid path names like /analytics, /events, or
/tracking, which some blockers filter by pattern.
The widget POSTs minimal JSON:
{
"type": "match",
"ts": "2026-06-01T12:00:00.000Z",
"topic": "Matrícula",
"itemId": "matricula"
}When it can't find an answer or needs to ask a clarifying question, it also sends the query text so the knowledge base can be improved:
{
"type": "fallback",
"ts": "2026-06-01T12:00:00.000Z",
"query": "cuando dan beca por el examen"
}Event types: match (confident match — topic/id only), fallback (no
match — the query is sent), clarification (ambiguous — the query is
sent), policy (a fixed policy/safety response — no query is sent). No PII
is collected; queries are sanitized before sending.
The server can store each event as one line in an .ndjson file, or process
it however the host site's real backend does — that part lives outside the
widget on purpose.
npm run dev # widget demo at http://localhost:5173
node analytics-endpoint/server.mjs # reference analytics server, in another terminalindex.html already points at /api/tec-chatbot; in dev, Vite proxies that
path to http://127.0.0.1:8080 (see vite.config.ts), so no IP needs to be
hardcoded in the HTML.
Events land in analytics/chatbot-events.ndjson. A live summary is served
at http://127.0.0.1:8080/api/tec-chatbot/summary; to write it to a file:
curl -X POST http://127.0.0.1:8080/api/tec-chatbot/reportwhich writes analytics/chatbot-summary.txt. To reset stats:
curl -X POST http://127.0.0.1:8080/api/tec-chatbot/clearIn production the endpoint must live on the real host site's server — the widget only sends events; it can't write files on its own once deployed as static HTML/JS.
analytics-endpoint/server.mjs is not required by the widget — it's a
reference implementation for whoever integrates the chatbot into a real
server. If that server can run Node.js, it can be run as-is:
node analytics-endpoint/server.mjsIt exposes:
POST /api/tec-chatbot
GET /api/tec-chatbot/summary
POST /api/tec-chatbot/report
POST /api/tec-chatbot/clearstoring events in analytics/chatbot-events.ndjson and reports in
analytics/chatbot-summary.txt. Same-domain paths
(analytics-endpoint="/api/tec-chatbot") are preferred in production over
cross-origin URLs.
If the endpoint is same-domain, there's no CORS issue. If it's on another domain/port, the endpoint server must explicitly allow the host page's origin, e.g.:
Access-Control-Allow-Origin: https://www.tec.ac.cr
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-TypeThe widget sends analytics with credentials: 'omit' (no cookies/session),
so Access-Control-Allow-Credentials isn't needed. Don't combine
Access-Control-Allow-Origin: * with credentials — browsers block it.
In this project, the allowed-origins list is hardcoded in
analytics-endpoint/server.mjs: currently http://localhost:5173 and
https://www.tec.ac.cr. Requests from any other origin get a 403.
Troubleshooting: blocked by CORS policy means the endpoint responded but
CORS isn't configured; ERR_CONNECTION_REFUSED means the endpoint isn't
running or the port is wrong; ERR_ADDRESS_UNREACHABLE/timeout points to
network/firewall issues; ERR_BLOCKED_BY_CLIENT means a browser extension
blocked it (use a neutral path like /api/tec-chatbot, avoid
/analytics//events//tracking); if curl works but the browser
doesn't, it's CORS or an extension — curl doesn't enforce CORS.
The chatbot must never invent answers. If it can't find a sufficiently confident match in the knowledge base, it defers to the contact email.