feat(assistant): v2 — query-language detection, analytics, chips, auto-index
Addresses real gaps found in testing (a Macedonian question on an English page returned nothing): - Detect the language of the QUESTION (Cyrillic -> MK; Slovene markers -> SI; else the page language) and load that language's index + curated answers. Fixes cross-language questions. - Expand curated multilingual intents (now incl. enrolment/eVŠ, deadlines, health, "what is MSOS", etc.) with stem keywords for better paraphrase matching. MK/SI answers machine-drafted — proofread before launch. - Suggested-question chips on open (localized) to guide users. - Privacy-safe analytics: fire a GA4 'assistant_query' event ONLY after the visitor accepts cookies (q text, result type, question vs page language) so the team can see real FAQs and unanswered questions. - Move the widget to bottom-left on pages with the floating "Become a member" CTA so they never overlap. Design polish (chips, typing dots, launcher pulse). - CI: rebuild the search index on every deploy (setup-python + build script) so the assistant stays in sync with content automatically. Build script is now path-portable (no hardcoded path). Verified in a headless browser: MK-on-EN now answers correctly, chips work, analytics event fires only with consent, become-a-member offset applies, zero console errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7f297bd65
commit
661943bb34
|
|
@ -13,6 +13,18 @@ jobs:
|
|||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Rebuild the assistant search index from the CURRENT content so it is
|
||||
# always in sync — no manual step needed when pages change.
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Rebuild assistant search index
|
||||
run: |
|
||||
python -m pip install --quiet beautifulsoup4
|
||||
python tools/build_search_index.py
|
||||
|
||||
- name: Deploy to Server via rsync
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
|
|
|
|||
|
|
@ -14,19 +14,30 @@
|
|||
z-index: 9999;
|
||||
font-family: inherit;
|
||||
}
|
||||
/* On the Become-a-member page the floating "Become a member" CTA sits
|
||||
bottom-right, so move the assistant to the bottom-left there. */
|
||||
.msa-root.msa-left { right: auto; left: 20px; }
|
||||
.msa-root.msa-left .msa-panel { right: auto; left: 0; }
|
||||
|
||||
/* launcher */
|
||||
.msa-launch {
|
||||
position: relative;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: var(--msa-accent); color: #fff;
|
||||
border: 0; border-radius: 999px;
|
||||
padding: 12px 18px; font-size: 15px; font-weight: 600;
|
||||
cursor: pointer; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
transition: transform .15s ease, background .15s ease;
|
||||
transition: transform .15s ease, background .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.msa-launch:hover { background: var(--msa-accent-dark); transform: translateY(-1px); }
|
||||
.msa-launch:hover { background: var(--msa-accent-dark); transform: translateY(-2px); box-shadow: 0 12px 30px rgba(0,0,0,.24); }
|
||||
.msa-launch i { font-size: 17px; }
|
||||
.msa-open .msa-launch { display: none; }
|
||||
/* gentle attention pulse on the launcher */
|
||||
.msa-launch::before {
|
||||
content: ""; position: absolute; inset: 0; border-radius: 999px;
|
||||
box-shadow: 0 0 0 0 rgba(18,112,126,.45); animation: msa-pulse 2.8s ease-out infinite;
|
||||
}
|
||||
@keyframes msa-pulse { 0%{box-shadow:0 0 0 0 rgba(18,112,126,.45);} 70%{box-shadow:0 0 0 14px rgba(18,112,126,0);} 100%{box-shadow:0 0 0 0 rgba(18,112,126,0);} }
|
||||
|
||||
/* panel */
|
||||
.msa-panel {
|
||||
|
|
@ -38,7 +49,25 @@
|
|||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
.msa-open .msa-panel { display: flex; }
|
||||
.msa-open .msa-panel { display: flex; animation: msa-rise .22s ease; }
|
||||
@keyframes msa-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* suggested-question chips */
|
||||
.msa-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.msa-chips-label { width: 100%; font-size: 12.5px; color: #5a6b72; margin-bottom: 2px; }
|
||||
.msa-chip {
|
||||
background: #fff; border: 1px solid var(--msa-accent); color: var(--msa-accent);
|
||||
border-radius: 999px; padding: 7px 12px; font-size: 13px; font-family: inherit;
|
||||
cursor: pointer; line-height: 1.2; transition: background .12s ease, color .12s ease;
|
||||
}
|
||||
.msa-chip:hover { background: var(--msa-accent); color: #fff; }
|
||||
|
||||
/* typing indicator */
|
||||
.msa-typing { display: inline-flex; gap: 4px; padding: 2px 0; }
|
||||
.msa-typing i { width: 7px; height: 7px; border-radius: 50%; background: #9fb0b6; display: inline-block; animation: msa-blink 1.2s infinite; }
|
||||
.msa-typing i:nth-child(2) { animation-delay: .2s; }
|
||||
.msa-typing i:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes msa-blink { 0%,80%,100%{ opacity:.3; } 40%{ opacity:1; } }
|
||||
|
||||
.msa-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
|
|
|
|||
397
assistant.js
397
assistant.js
|
|
@ -1,278 +1,313 @@
|
|||
/* ==========================================================================
|
||||
MSOS Assistant — offline "find my answer" helper.
|
||||
100% in-browser: no API, no server, no cost. Lazy-loads a small per-language
|
||||
index and answers with a short snippet + a link to the full page.
|
||||
MSOS Assistant — offline "find my answer" helper (v2).
|
||||
100% in-browser: no API, no server, no cost. Detects the language of the
|
||||
QUESTION (not just the page), lazy-loads that language's index, matches a
|
||||
curated multilingual answer layer + BM25 search, and links to the page.
|
||||
Dependency-free so it stays CSP-safe (script-src 'self').
|
||||
========================================================================== */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var LANG = (document.documentElement.lang || "en").toLowerCase();
|
||||
if (LANG === "sl") LANG = "si"; // site uses lang="sl", files use "si"
|
||||
if (["en", "mk", "si"].indexOf(LANG) < 0) LANG = "en";
|
||||
function normLang(l) { l = (l || "en").toLowerCase(); if (l === "sl") l = "si"; return ["en", "mk", "si"].indexOf(l) < 0 ? "en" : l; }
|
||||
var PAGE_LANG = normLang(document.documentElement.lang);
|
||||
|
||||
/* ---- localized UI strings ---- */
|
||||
var UI = {
|
||||
en: { title: "Ask MSOS", open: "Ask MSOS", placeholder: "Ask a question…",
|
||||
send: "Send", close: "Close",
|
||||
greeting: "Hi! 👋 Ask me anything about studying in Slovenia or MSOS and I'll point you to the right page.",
|
||||
found: "Here's what I found:", more: "Read more",
|
||||
none: "I couldn't find a clear answer. Try the Student Welcome Guide, or email info@msosorg.com.",
|
||||
var UIS = {
|
||||
en: { title: "Ask MSOS", open: "Ask MSOS", placeholder: "Ask a question…", send: "Send", close: "Close",
|
||||
greeting: "Hi! 👋 Ask me anything about studying in Slovenia or about MSOS — I'll point you to the right page. You can ask in English, Macedonian or Slovene.",
|
||||
found: "Here's what I found:", also: "You might also like:", more: "Read more",
|
||||
none: "I couldn't find a clear answer for that. These might help:", tryLabel: "Popular questions:",
|
||||
guideUrl: "/en/student-welcome-guide/", disclaimer: "Answers come straight from this website." },
|
||||
mk: { title: "Прашај MSOS", open: "Прашај MSOS", placeholder: "Постави прашање…",
|
||||
send: "Испрати", close: "Затвори",
|
||||
greeting: "Здраво! 👋 Прашај ме било што за студирање во Словенија или за MSOS и ќе те упатам до вистинската страница.",
|
||||
found: "Еве што најдов:", more: "Прочитај повеќе",
|
||||
none: "Не најдов јасен одговор. Пробај го Водичот за студенти или пиши на info@msosorg.com.",
|
||||
mk: { title: "Прашај MSOS", open: "Прашај MSOS", placeholder: "Постави прашање…", send: "Испрати", close: "Затвори",
|
||||
greeting: "Здраво! 👋 Прашај ме било што за студирање во Словенија или за MSOS — ќе те упатам до вистинската страница. Можеш да прашаш на македонски, англиски или словенечки.",
|
||||
found: "Еве што најдов:", also: "Можеби ќе ти користи и:", more: "Прочитај повеќе",
|
||||
none: "Не најдов јасен одговор. Ова можеби ќе помогне:", tryLabel: "Популарни прашања:",
|
||||
guideUrl: "/mk/student-welcome-guide/", disclaimer: "Одговорите доаѓаат директно од оваа страница." },
|
||||
si: { title: "Vprašaj MSOS", open: "Vprašaj MSOS", placeholder: "Zastavi vprašanje…",
|
||||
send: "Pošlji", close: "Zapri",
|
||||
greeting: "Živjo! 👋 Vprašaj me karkoli o študiju v Sloveniji ali o MSOS in usmeril te bom na pravo stran.",
|
||||
found: "Tole sem našel:", more: "Preberi več",
|
||||
none: "Nisem našel jasnega odgovora. Poglej Vodnik za študente ali piši na info@msosorg.com.",
|
||||
si: { title: "Vprašaj MSOS", open: "Vprašaj MSOS", placeholder: "Zastavi vprašanje…", send: "Pošlji", close: "Zapri",
|
||||
greeting: "Živjo! 👋 Vprašaj me karkoli o študiju v Sloveniji ali o MSOS — usmeril te bom na pravo stran. Vprašaš lahko v slovenščini, angleščini ali makedonščini.",
|
||||
found: "Tole sem našel:", also: "Morda te zanima tudi:", more: "Preberi več",
|
||||
none: "Nisem našel jasnega odgovora. Morda pomaga to:", tryLabel: "Pogosta vprašanja:",
|
||||
guideUrl: "/si/student-welcome-guide/", disclaimer: "Odgovori prihajajo neposredno s te spletne strani." }
|
||||
}[LANG];
|
||||
};
|
||||
|
||||
/* ---- curated short answers for common questions (checked before search).
|
||||
EN is complete; MK/SI seed a few — machine-drafted, PROOFREAD before launch.
|
||||
Add more freely: {k:[keywords], a:"short answer", u:"/lang/page/"} ---- */
|
||||
/* ---- suggested starter questions (clickable chips) ---- */
|
||||
var CHIPS = {
|
||||
en: ["How do I enrol at a university?", "Residence permit?", "Cost of living in Slovenia?", "How to become a member?"],
|
||||
mk: ["Како да се запишам на факултет?", "Дозвола за престој?", "Трошоци за живот во Словенија?", "Како да станам член?"],
|
||||
si: ["Kako se vpišem na fakulteto?", "Dovoljenje za prebivanje?", "Stroški življenja v Sloveniji?", "Kako postanem član?"]
|
||||
};
|
||||
|
||||
/* ---- curated short answers, per language. Keywords are matched as
|
||||
substrings (so short stems like "запиш" catch "запишам/запишување").
|
||||
EN is the reference; MK/SI are machine-drafted — PROOFREAD before launch.
|
||||
Add more anytime: {k:[keywords], a:"short answer", u:"/lang/page/"} ---- */
|
||||
var INTENTS = {
|
||||
en: [
|
||||
{ k: ["residence", "permit", "temporary", "stay"], u: "/en/student-welcome-guide/student-residence-permit/",
|
||||
a: "You apply for a temporary residence permit for study (usually before or right after arrival). It takes several documents and steps and is valid ~1 year, then renewed." },
|
||||
{ k: ["enrol", "enroll", "enrolment", "register at", "evš", "evs", "admission", "admissio", "apply to univ", "sign up for", "faculty", "university place"], u: "/en/student-welcome-guide/evs-application-guide/",
|
||||
a: "You apply through the eVŠ portal. The eVŠ guide walks through creating your application, choosing programmes, and the deadlines." },
|
||||
{ k: ["residence", "permit", "temporary stay", "stay in slove"], u: "/en/student-welcome-guide/student-residence-permit/",
|
||||
a: "Non-EU students apply for a temporary residence permit for study — several documents and steps, valid ~1 year, then renewed." },
|
||||
{ k: ["visa"], u: "/en/student-welcome-guide/student-residence-permit/",
|
||||
a: "Non-EU students generally need a residence permit for study rather than a short visa. See the residence-permit guide for the documents and steps." },
|
||||
{ k: ["cost", "living", "expenses", "budget", "money", "rent"], u: "/en/get-to-know-slovenia/",
|
||||
a: "Non-EU students generally need a residence permit for study rather than a short visa. See the residence-permit guide for documents and steps." },
|
||||
{ k: ["cost", "living", "expenses", "budget", "how much money", "prices"], u: "/en/get-to-know-slovenia/",
|
||||
a: "Monthly living costs depend on your city and housing. The guide breaks down rent, food, transport and student discounts." },
|
||||
{ k: ["accommodation", "housing", "dorm", "apartment", "room", "student home"], u: "/en/student-welcome-guide/student-accommodation/",
|
||||
{ k: ["accommodation", "housing", "dorm", "apartment", "where to live", "room", "student home", "rent"], u: "/en/student-welcome-guide/student-accommodation/",
|
||||
a: "You can look at student dorms and private rentals. The accommodation guide explains how and where to search." },
|
||||
{ k: ["english", "slovene", "slovenian", "language"], u: "/en/student-welcome-guide/",
|
||||
{ k: ["english", "slovene", "slovenian language", "in english", "language requirement"], u: "/en/student-welcome-guide/",
|
||||
a: "Some programmes are in English; many are in Slovene. Learning basic Slovene helps a lot with daily life and admin." },
|
||||
{ k: ["work", "job", "part-time", "student work"], u: "/en/student-welcome-guide/",
|
||||
a: "Students can work part-time through student work ('študentsko delo'). The guide covers how it works alongside studies." },
|
||||
{ k: ["boni", "benefits", "discount", "meal", "subsidised"], u: "/en/student-welcome-guide/",
|
||||
{ k: ["work", "job", "part-time", "student work", "earn"], u: "/en/student-welcome-guide/",
|
||||
a: "Students can work part-time through student work ('študentsko delo'). The guide covers how it fits alongside studies." },
|
||||
{ k: ["boni", "benefit", "discount", "meal", "subsid"], u: "/en/student-welcome-guide/",
|
||||
a: "Students get subsidised meals ('boni') and many discounts. See the guide for how to register and use them." },
|
||||
{ k: ["tuition", "fees", "scholarship", "funding", "ad futura"], u: "/en/student-welcome-guide/application-deadlines/",
|
||||
{ k: ["tuition", "fee", "scholarship", "funding", "ad futura", "grant"], u: "/en/student-welcome-guide/application-deadlines/",
|
||||
a: "Tuition varies by programme and status; scholarships like Ad Futura exist. Check deadlines and funding in the guide." },
|
||||
{ k: ["bank", "account", "iban"], u: "/en/blog/how-to-open-slovenian-bank-account/",
|
||||
{ k: ["bank", "account", "iban", "open a bank"], u: "/en/blog/how-to-open-slovenian-bank-account/",
|
||||
a: "Opening a Slovenian bank account is straightforward with your documents. The blog post walks through it step by step." },
|
||||
{ k: ["enrol", "enrolment", "evš", "evs", "apply", "application", "admission"], u: "/en/student-welcome-guide/evs-application-guide/",
|
||||
a: "You apply through the eVŠ portal. The eVŠ guide shows exactly how to submit your application." },
|
||||
{ k: ["arrival", "checklist", "first", "when i arrive", "moving"], u: "/en/student-welcome-guide/arrival-checklist/",
|
||||
{ k: ["health", "insurance", "doctor", "medical"], u: "/en/student-welcome-guide/",
|
||||
a: "You'll need health insurance as a student. The Student Welcome Guide explains how to arrange it after you arrive." },
|
||||
{ k: ["arrival", "checklist", "when i arrive", "first week", "moving", "just arrived"], u: "/en/student-welcome-guide/arrival-checklist/",
|
||||
a: "There's a first-weeks arrival checklist (registration, permit, bank, health, etc.) to keep you on track." },
|
||||
{ k: ["member", "join", "membership", "become"], u: "/en/become-a-member/",
|
||||
{ k: ["deadline", "when to apply", "dates"], u: "/en/student-welcome-guide/application-deadlines/",
|
||||
a: "Application deadlines depend on the round and programme. The deadlines page lists the key dates to watch." },
|
||||
{ k: ["member", "join", "membership", "become a member"], u: "/en/become-a-member/",
|
||||
a: "You can join in a couple of minutes — students and supporters both welcome. Use the Become a member form." },
|
||||
{ k: ["support", "donate", "donation", "sponsor", "help"], u: "/en/support/",
|
||||
{ k: ["support", "donate", "donation", "sponsor", "help you"], u: "/en/support/",
|
||||
a: "You can support MSOS by bank transfer, sponsorship or partnership. See the Support page for details." },
|
||||
{ k: ["contact", "email", "reach", "get in touch"], u: "/en/legal/",
|
||||
a: "You can reach MSOS at info@msosorg.com. Official details are on the Official information page." }
|
||||
{ k: ["contact", "email", "reach you", "get in touch", "phone"], u: "/en/legal/",
|
||||
a: "You can reach MSOS at info@msosorg.com. Official details are on the Official information page." },
|
||||
{ k: ["ohrid"], u: "/en/get-to-know-macedonia/ohrid-more-than-a-postcard/", a: "Ohrid is a UNESCO lakeside town — old bazaar, churches and Kaneo. The 'Get to know Macedonia' story covers what to see." },
|
||||
{ k: ["who are you", "what is msos", "about msos", "about you"], u: "/en/about-us/", a: "MSOS is the first Macedonian Student Organisation in Slovenia — support, community and culture for Macedonian students. See About us." }
|
||||
],
|
||||
mk: [
|
||||
{ k: ["дозвол", "престој", "виза"], u: "/mk/student-welcome-guide/student-residence-permit/",
|
||||
a: "Аплицираш за дозвола за привремен престој за студирање. Потребни се неколку документи и чекори; важи околу 1 година." },
|
||||
{ k: ["запиш", "упис", "факултет", "студи", "evš", "evs", "аплиц", "прием", "универзитет"], u: "/mk/student-welcome-guide/evs-application-guide/",
|
||||
a: "Аплицираш преку порталот eVŠ. Водичот за eVŠ те води чекор по чекор низ пријавата, изборот на програми и роковите." },
|
||||
{ k: ["дозвол", "престој", "виза", "бивање"], u: "/mk/student-welcome-guide/student-residence-permit/",
|
||||
a: "Аплицираш за дозвола за привремен престој за студирање — потребни се неколку документи и чекори; важи околу 1 година." },
|
||||
{ k: ["трошоц", "живот", "цени", "пари", "буџет"], u: "/mk/get-to-know-slovenia/",
|
||||
a: "Месечните трошоци зависат од градот и сместувањето. Водичот објаснува киринина, храна, превоз и попусти." },
|
||||
{ k: ["сместув", "стан", "дом", "соба", "кирија", "живеа"], u: "/mk/student-welcome-guide/student-accommodation/",
|
||||
a: "Можеш да бараш студентски домови или приватно сместување. Водичот објаснува како и каде да бараш." },
|
||||
{ k: ["работ", "хонорар", "студентск дело", "заработ"], u: "/mk/student-welcome-guide/",
|
||||
a: "Студентите можат да работат преку студентско дело. Водичот објаснува како да го усогласиш со студиите." },
|
||||
{ k: ["бони", "попуст", "оброк", "субвенц", "бенефит"], u: "/mk/student-welcome-guide/",
|
||||
a: "Студентите добиваат субвенционирани оброци („бони“) и многу попусти. Види во водичот како да ги искористиш." },
|
||||
{ k: ["стипенд", "школарина", "финанс", "ad futura"], u: "/mk/student-welcome-guide/application-deadlines/",
|
||||
a: "Школарината зависи од програмата и статусот; постојат стипендии како Ad Futura. Провери ги роковите во водичот." },
|
||||
{ k: ["банк", "сметка", "iban"], u: "/mk/blog/how-to-open-slovenian-bank-account/",
|
||||
a: "Отворањето банкарска сметка во Словенија е едноставно со документите. Блог објавата објаснува чекор по чекор." },
|
||||
{ k: ["рок", "кога да аплиц", "датум"], u: "/mk/student-welcome-guide/application-deadlines/",
|
||||
a: "Роковите зависат од кругот и програмата. Страницата со рокови ги наведува клучните датуми." },
|
||||
{ k: ["член", "зачлен", "приклуч"], u: "/mk/become-a-member/",
|
||||
a: "Можеш да се зачлениш за неколку минути — добредојдени се и студенти и поддржувачи." },
|
||||
{ k: ["поддрш", "донац", "донир"], u: "/mk/support/",
|
||||
{ k: ["поддрш", "донац", "донир", "спонз"], u: "/mk/support/",
|
||||
a: "Можеш да го поддржиш MSOS преку банкарски трансфер, спонзорство или партнерство. Види ја страницата Поддршка." },
|
||||
{ k: ["сместув", "стан", "дом"], u: "/mk/student-welcome-guide/student-accommodation/",
|
||||
a: "Можеш да бараш студентски домови или приватно сместување. Водичот објаснува како и каде." },
|
||||
{ k: ["контакт", "е-пошта", "мејл"], u: "/mk/legal/",
|
||||
a: "Контактирај го MSOS на info@msosorg.com. Официјалните податоци се на страницата Официјални информации." }
|
||||
{ k: ["контакт", "е-пошта", "мејл", "телефон"], u: "/mk/legal/",
|
||||
a: "Контактирај го MSOS на info@msosorg.com. Официјалните податоци се на страницата Официјални информации." },
|
||||
{ k: ["охрид"], u: "/mk/get-to-know-macedonia/ohrid-more-than-a-postcard/", a: "Охрид е езерски град под УНЕСКО — стара чаршија, цркви и Канео. Приказната „Запознај ја Македонија“ покажува што да видиш." }
|
||||
],
|
||||
si: [
|
||||
{ k: ["vpis", "vpiš", "fakultet", "študij", "evš", "evs", "prijav", "sprejem", "univerz"], u: "/si/student-welcome-guide/evs-application-guide/",
|
||||
a: "Prijaviš se prek portala eVŠ. Vodnik za eVŠ te vodi skozi prijavo, izbiro programov in roke." },
|
||||
{ k: ["dovoljenje", "prebivanje", "vizum", "bivanje"], u: "/si/student-welcome-guide/student-residence-permit/",
|
||||
a: "Zaprosiš za dovoljenje za začasno prebivanje zaradi študija. Potrebnih je nekaj dokumentov in korakov; velja približno 1 leto." },
|
||||
a: "Zaprosiš za dovoljenje za začasno prebivanje zaradi študija — nekaj dokumentov in korakov; velja približno 1 leto." },
|
||||
{ k: ["stroš", "življenj", "cene", "denar", "proračun"], u: "/si/get-to-know-slovenia/",
|
||||
a: "Mesečni stroški so odvisni od mesta in nastanitve. Vodnik razčleni najemnino, hrano, prevoz in popuste." },
|
||||
{ k: ["nastanit", "stanovanje", "dom", "soba", "najem", "kje živeti"], u: "/si/student-welcome-guide/student-accommodation/",
|
||||
a: "Iščeš lahko študentske domove ali zasebne najeme. Vodnik pojasni, kako in kje iskati." },
|
||||
{ k: ["delo", "služba", "študentsko delo", "zaslužit"], u: "/si/student-welcome-guide/",
|
||||
a: "Študenti lahko delajo prek študentskega dela. Vodnik pojasni, kako to poteka ob študiju." },
|
||||
{ k: ["boni", "popust", "obrok", "subvenc"], u: "/si/student-welcome-guide/",
|
||||
a: "Študenti dobijo subvencionirane obroke (bone) in številne popuste. V vodniku piše, kako jih uporabiš." },
|
||||
{ k: ["štipend", "šolnin", "financ", "ad futura"], u: "/si/student-welcome-guide/application-deadlines/",
|
||||
a: "Šolnina je odvisna od programa in statusa; obstajajo štipendije, npr. Ad Futura. Roke preveri v vodniku." },
|
||||
{ k: ["bank", "račun", "iban"], u: "/si/blog/how-to-open-slovenian-bank-account/",
|
||||
a: "Odprtje slovenskega bančnega računa je z dokumenti enostavno. Blog objava te vodi korak za korakom." },
|
||||
{ k: ["rok", "kdaj se prijav", "datum"], u: "/si/student-welcome-guide/application-deadlines/",
|
||||
a: "Roki so odvisni od kroga in programa. Stran z roki navaja ključne datume." },
|
||||
{ k: ["član", "včlan", "pridruž"], u: "/si/become-a-member/",
|
||||
a: "Včlaniš se lahko v nekaj minutah — dobrodošli so študenti in podporniki." },
|
||||
{ k: ["podpri", "donacij", "donir"], u: "/si/support/",
|
||||
{ k: ["podpri", "donacij", "donir", "sponz"], u: "/si/support/",
|
||||
a: "MSOS lahko podpreš z bančnim nakazilom, sponzorstvom ali partnerstvom. Glej stran Podpri." },
|
||||
{ k: ["nastanit", "stanovanje", "dom", "soba"], u: "/si/student-welcome-guide/student-accommodation/",
|
||||
a: "Iščeš lahko študentske domove ali zasebne najeme. Vodnik pojasni, kako in kje." },
|
||||
{ k: ["kontakt", "e-pošta", "mail"], u: "/si/legal/",
|
||||
a: "MSOS dosežeš na info@msosorg.com. Uradni podatki so na strani Uradni podatki." }
|
||||
{ k: ["kontakt", "e-pošta", "mail", "telefon"], u: "/si/legal/",
|
||||
a: "MSOS dosežeš na info@msosorg.com. Uradni podatki so na strani Uradni podatki." },
|
||||
{ k: ["ohrid"], u: "/si/get-to-know-macedonia/ohrid-more-than-a-postcard/", a: "Ohrid je jezersko mesto pod Unescom — stara tržnica, cerkve in Kaneo. Zgodba 'Spoznaj Makedonijo' pokaže, kaj videti." }
|
||||
]
|
||||
}[LANG] || [];
|
||||
};
|
||||
|
||||
/* ---- text normalization + tokenizer ---- */
|
||||
function norm(s) { s = (s || "").toLowerCase(); try { s = s.normalize("NFD").replace(/[̀-ͯ]/g, ""); } catch (e) {} return s; }
|
||||
function tokens(s) { var out = [], m = norm(s).match(/[\p{L}\p{N}]+/gu); if (m) for (var i = 0; i < m.length; i++) if (m[i].length > 1) out.push(m[i]); return out; }
|
||||
|
||||
/* ---- text normalization: lowercase + fold Latin diacritics (č→c…),
|
||||
keep Cyrillic. Tokenize on non-letter/digit (Unicode-aware). ---- */
|
||||
function norm(s) {
|
||||
s = (s || "").toLowerCase();
|
||||
try { s = s.normalize("NFD").replace(/[̀-ͯ]/g, ""); } catch (e) {}
|
||||
return s;
|
||||
}
|
||||
function tokens(s) {
|
||||
var out = [], m = norm(s).match(/[\p{L}\p{N}]+/gu);
|
||||
if (m) for (var i = 0; i < m.length; i++) if (m[i].length > 1) out.push(m[i]);
|
||||
return out;
|
||||
}
|
||||
// Question/filler words in EN + MK + SI; removed from queries so the
|
||||
// meaningful terms (e.g. "ohrid", "permit") drive the ranking.
|
||||
var STOP = (function () {
|
||||
var w = ("the a an of to in on for and or is are be was were do does did how what " +
|
||||
"when where why who which can could would should will my your our we they it this " +
|
||||
"that these those with at as by from about into over out up down me you i us them " +
|
||||
"have has had need want please get got give tell show find help hi hello hey thanks " +
|
||||
"kako kaj kje kdaj zakaj kdo kateri ali je so bo bi na za in ali da li mi me ti to ta " +
|
||||
"te tega se z s v iz pri od do imam imate zelim moram lahko prosim zivjo pozdravljeni " +
|
||||
"kako sto dali koi koga kade zosto koj koja e se na vo za i ili so da li mi me ti nie " +
|
||||
"toa ova imam sakam treba moze prosim zdravo ").split(/\s+/);
|
||||
var w = ("the a an of to in on for and or is are be was were do does did how what when where why who which can could would should will my your our we they it this " +
|
||||
"that these those with at as by from about into over out up down me you i us them have has had need want please get got give tell show find help hi hello hey thanks " +
|
||||
"kako kaj kje kdaj zakaj kdo kateri ali je so bo bi na za in ali da li mi me ti to ta te tega se z s v iz pri od do imam imate zelim moram lahko prosim zivjo pozdravljeni " +
|
||||
"kako sto dali koi koga kade zosto koj koja e se na vo za i ili so da li mi me ti nie toa ova imam sakam treba moze prosim zdravo").split(/\s+/);
|
||||
var o = {}; for (var i = 0; i < w.length; i++) if (w[i]) o[w[i]] = 1; return o;
|
||||
})();
|
||||
|
||||
/* ---- tiny BM25 search over the lazily-loaded index ---- */
|
||||
var INDEX = null, DF = null, AVGLEN = 0, ready = false, loading = false;
|
||||
|
||||
function loadIndex() {
|
||||
if (ready || loading) return Promise.resolve();
|
||||
loading = true;
|
||||
return fetch("/assistant/index-" + LANG + ".json")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
INDEX = data; DF = {}; var total = 0;
|
||||
for (var i = 0; i < INDEX.length; i++) {
|
||||
var c = INDEX[i];
|
||||
c._t = tokens(c.x + " " + c.h + " " + c.t);
|
||||
c._len = c._t.length; total += c._len;
|
||||
var seen = {};
|
||||
for (var j = 0; j < c._t.length; j++) {
|
||||
var w = c._t[j];
|
||||
if (!seen[w]) { seen[w] = 1; DF[w] = (DF[w] || 0) + 1; }
|
||||
}
|
||||
}
|
||||
AVGLEN = total / Math.max(INDEX.length, 1);
|
||||
ready = true; loading = false;
|
||||
})
|
||||
.catch(function () { loading = false; });
|
||||
/* ---- detect the language of the QUESTION (not just the page) ---- */
|
||||
function detectLang(text) {
|
||||
if (/[Ѐ-ӿ]/.test(text)) return "mk"; // Cyrillic → Macedonian
|
||||
var t = " " + norm(text) + " ";
|
||||
var siHits = (t.match(/ (vpis|kako|kje|stanovanje|studij|dovoljenje|prebivanje|clan|stroski|zelim|prijav|fakulteto|kaj) /g) || []).length;
|
||||
var enHits = (t.match(/ (how|what|where|the|residence|permit|university|cost|member|want|apply|study|enrol) /g) || []).length;
|
||||
if (siHits > enHits && siHits > 0) return "si";
|
||||
if (enHits > 0) return "en";
|
||||
return PAGE_LANG; // fall back to the page
|
||||
}
|
||||
|
||||
function search(query, k) {
|
||||
if (!ready) return [];
|
||||
/* ---- per-language index cache + BM25 ---- */
|
||||
var CACHE = {}; // lang -> {docs, DF, AVG, N}
|
||||
function loadIndex(lang) {
|
||||
if (CACHE[lang]) return Promise.resolve(CACHE[lang]);
|
||||
return fetch("/assistant/index-" + lang + ".json").then(function (r) { return r.json(); }).then(function (docs) {
|
||||
var DF = {}, total = 0;
|
||||
for (var i = 0; i < docs.length; i++) {
|
||||
var c = docs[i]; c._t = tokens(c.x + " " + c.h + " " + c.t); c._len = c._t.length; total += c._len;
|
||||
var seen = {}; for (var j = 0; j < c._t.length; j++) { var w = c._t[j]; if (!seen[w]) { seen[w] = 1; DF[w] = (DF[w] || 0) + 1; } }
|
||||
}
|
||||
CACHE[lang] = { docs: docs, DF: DF, AVG: total / Math.max(docs.length, 1), N: docs.length };
|
||||
return CACHE[lang];
|
||||
}).catch(function () { return null; });
|
||||
}
|
||||
|
||||
function search(ix, query, k) {
|
||||
if (!ix) return [];
|
||||
var q0 = tokens(query); if (!q0.length) return [];
|
||||
var N = INDEX.length, k1 = 1.5, b = 0.75, scored = [];
|
||||
// Drop stopwords + very common terms so rare, meaningful words win.
|
||||
var q = q0.filter(function (t) { return !STOP[t] && (!DF[t] || DF[t] / N <= 0.4); });
|
||||
var q = q0.filter(function (t) { return !STOP[t] && (!ix.DF[t] || ix.DF[t] / ix.N <= 0.4); });
|
||||
if (!q.length) q = q0.filter(function (t) { return !STOP[t]; });
|
||||
if (!q.length) q = q0;
|
||||
for (var i = 0; i < N; i++) {
|
||||
var c = INDEX[i], score = 0, tf = {};
|
||||
var k1 = 1.5, b = 0.75, scored = [];
|
||||
for (var i = 0; i < ix.docs.length; i++) {
|
||||
var c = ix.docs[i], score = 0, tf = {};
|
||||
for (var j = 0; j < c._t.length; j++) tf[c._t[j]] = (tf[c._t[j]] || 0) + 1;
|
||||
for (var t = 0; t < q.length; t++) {
|
||||
var term = q[t], f = tf[term]; if (!f) continue;
|
||||
var idf = Math.log(1 + (N - DF[term] + 0.5) / (DF[term] + 0.5));
|
||||
score += idf * (f * (k1 + 1)) / (f + k1 * (1 - b + b * c._len / AVGLEN));
|
||||
var idf = Math.log(1 + (ix.N - ix.DF[term] + 0.5) / (ix.DF[term] + 0.5));
|
||||
score += idf * (f * (k1 + 1)) / (f + k1 * (1 - b + b * c._len / ix.AVG));
|
||||
}
|
||||
// boost when the query hits the heading/title
|
||||
var hn = norm(c.h + " " + c.t);
|
||||
for (var h = 0; h < q.length; h++) if (hn.indexOf(q[h]) >= 0) score += 0.6;
|
||||
for (var h = 0; h < q.length; h++) if (hn.indexOf(q[h]) >= 0) score += 0.8;
|
||||
if (score > 0) scored.push([score, c]);
|
||||
}
|
||||
scored.sort(function (a, b) { return b[0] - a[0]; });
|
||||
return scored.slice(0, k || 3).map(function (s) { return s[1]; });
|
||||
// de-dupe by URL so we don't show the same page twice
|
||||
var out = [], seenU = {};
|
||||
for (var s = 0; s < scored.length && out.length < (k || 3); s++) {
|
||||
var u = scored[s][1].u; if (seenU[u]) continue; seenU[u] = 1; out.push(scored[s][1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function matchIntent(query) {
|
||||
var q = norm(query), best = null, bestHits = 0;
|
||||
for (var i = 0; i < INTENTS.length; i++) {
|
||||
function matchIntent(lang, query) {
|
||||
var list = INTENTS[lang] || [], q = norm(query), best = null, bestHits = 0;
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var hits = 0;
|
||||
for (var j = 0; j < INTENTS[i].k.length; j++)
|
||||
if (q.indexOf(norm(INTENTS[i].k[j])) >= 0) hits++;
|
||||
if (hits > bestHits) { bestHits = hits; best = INTENTS[i]; }
|
||||
for (var j = 0; j < list[i].k.length; j++) if (q.indexOf(norm(list[i].k[j])) >= 0) hits++;
|
||||
if (hits > bestHits) { bestHits = hits; best = list[i]; }
|
||||
}
|
||||
return bestHits > 0 ? best : null;
|
||||
}
|
||||
|
||||
/* ---- snippet: ~180 chars of a chunk, trimmed to a sentence ---- */
|
||||
function snippet(c) {
|
||||
var x = c.x;
|
||||
var lead = c.h && x.indexOf(c.h) === 0 ? x.slice(c.h.length).replace(/^[.\s]+/, "") : x;
|
||||
var x = c.x, lead = c.h && x.indexOf(c.h) === 0 ? x.slice(c.h.length).replace(/^[.\s]+/, "") : x;
|
||||
if (lead.length > 200) lead = lead.slice(0, 200).replace(/\s+\S*$/, "") + "…";
|
||||
return lead;
|
||||
}
|
||||
|
||||
/* ========================= UI ========================= */
|
||||
var esc = function (s) { return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'"); };
|
||||
|
||||
var root, panel, log, input, launch, opened = false;
|
||||
|
||||
function bubble(text, who, extraHTML) {
|
||||
var b = document.createElement("div");
|
||||
b.className = "msa-msg msa-" + who;
|
||||
b.innerHTML = '<div class="msa-bubble">' + text + (extraHTML || "") + "</div>";
|
||||
log.appendChild(b);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
return b;
|
||||
/* ---- privacy-safe analytics: only if the visitor accepted cookies ---- */
|
||||
function track(query, resultType, lang) {
|
||||
try {
|
||||
if (localStorage.getItem("msos-consent") !== "granted") return;
|
||||
if (typeof window.gtag !== "function") return;
|
||||
window.gtag("event", "assistant_query", {
|
||||
q: String(query).slice(0, 120), result: resultType, q_lang: lang, page_lang: PAGE_LANG
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/* ========================= UI ========================= */
|
||||
var esc = function (s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); };
|
||||
var UI = UIS[PAGE_LANG];
|
||||
var root, panel, log, input, launch, chipsWrap, opened = false;
|
||||
|
||||
function bubble(html, who) {
|
||||
var b = document.createElement("div");
|
||||
b.className = "msa-msg msa-" + who;
|
||||
b.innerHTML = '<div class="msa-bubble">' + html + "</div>";
|
||||
log.appendChild(b); log.scrollTop = log.scrollHeight; return b;
|
||||
}
|
||||
function linkHTML(url, label) { return '<a class="msa-link" href="' + esc(url) + '">' + esc(label || UI.more) + ' →</a>'; }
|
||||
|
||||
function answer(query) {
|
||||
if (chipsWrap) chipsWrap.style.display = "none";
|
||||
bubble(esc(query), "user");
|
||||
var typing = bubble("…", "bot");
|
||||
Promise.resolve(ready ? null : loadIndex()).then(function () {
|
||||
var typing = bubble('<span class="msa-typing"><i></i><i></i><i></i></span>', "bot");
|
||||
var lang = detectLang(query);
|
||||
loadIndex(lang).then(function (ix) {
|
||||
typing.remove();
|
||||
var intent = matchIntent(query);
|
||||
var hits = search(query, 3);
|
||||
var intent = matchIntent(lang, query);
|
||||
var hits = search(ix, query, 3);
|
||||
if (intent) {
|
||||
var extra = '<a class="msa-link" href="' + esc(intent.u) + '">' + esc(UI.more) + ' →</a>';
|
||||
bubble(esc(intent.a), "bot", extra);
|
||||
// offer one more related page from search if different
|
||||
var extraHit = hits.filter(function (c) { return c.u !== intent.u; })[0];
|
||||
if (extraHit) bubble('<span class="msa-also">' + esc(UI.found) + '</span> ' +
|
||||
esc(extraHit.h) + ' <a class="msa-link" href="' + esc(extraHit.u) + '">' + esc(UI.more) + ' →</a>', "bot");
|
||||
return;
|
||||
bubble(esc(intent.a) + "<br>" + linkHTML(intent.u), "bot");
|
||||
var extra = hits.filter(function (c) { return c.u.replace(/\/$/, "") !== intent.u.replace(/\/$/, ""); })[0];
|
||||
if (extra) bubble('<span class="msa-also">' + esc(UI.also) + '</span><br><strong>' + esc(extra.h) + "</strong><br>" + linkHTML(extra.u), "bot");
|
||||
track(query, "curated", lang); return;
|
||||
}
|
||||
if (hits.length) {
|
||||
bubble('<span class="msa-also">' + esc(UI.found) + '</span>', "bot");
|
||||
hits.forEach(function (c) {
|
||||
bubble('<strong>' + esc(c.h) + '</strong><br>' + esc(snippet(c)) +
|
||||
'<br><a class="msa-link" href="' + esc(c.u) + '">' + esc(UI.more) + ' →</a>', "bot");
|
||||
});
|
||||
hits.forEach(function (c) { bubble("<strong>" + esc(c.h) + "</strong><br>" + esc(snippet(c)) + "<br>" + linkHTML(c.u), "bot"); });
|
||||
track(query, "search", lang);
|
||||
} else {
|
||||
bubble(esc(UI.none) + ' <a class="msa-link" href="' + esc(UI.guideUrl) + '">' + esc(UI.more) + ' →</a>', "bot");
|
||||
var guide = (UIS[lang] || UI).guideUrl;
|
||||
bubble(esc((UIS[lang] || UI).none) + "<br>" + linkHTML(guide, UI.more), "bot");
|
||||
track(query, "none", lang);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
chipsWrap = document.createElement("div");
|
||||
chipsWrap.className = "msa-chips";
|
||||
chipsWrap.innerHTML = '<span class="msa-chips-label">' + esc(UI.tryLabel) + "</span>";
|
||||
CHIPS[PAGE_LANG].forEach(function (q) {
|
||||
var c = document.createElement("button");
|
||||
c.type = "button"; c.className = "msa-chip"; c.textContent = q;
|
||||
c.addEventListener("click", function () { answer(q); });
|
||||
chipsWrap.appendChild(c);
|
||||
});
|
||||
log.appendChild(chipsWrap);
|
||||
}
|
||||
|
||||
function open() {
|
||||
opened = true; root.classList.add("msa-open");
|
||||
panel.setAttribute("aria-hidden", "false");
|
||||
launch.setAttribute("aria-expanded", "true");
|
||||
loadIndex();
|
||||
if (!log.childNodes.length) bubble(esc(UI.greeting), "bot");
|
||||
setTimeout(function () { input.focus(); }, 50);
|
||||
}
|
||||
function close() {
|
||||
opened = false; root.classList.remove("msa-open");
|
||||
panel.setAttribute("aria-hidden", "true");
|
||||
launch.setAttribute("aria-expanded", "false");
|
||||
launch.focus();
|
||||
}
|
||||
function submit() {
|
||||
var q = input.value.trim(); if (!q) return;
|
||||
input.value = ""; answer(q);
|
||||
panel.setAttribute("aria-hidden", "false"); launch.setAttribute("aria-expanded", "true");
|
||||
if (!log.childNodes.length) { bubble(esc(UI.greeting), "bot"); renderChips(); loadIndex(PAGE_LANG); }
|
||||
setTimeout(function () { input.focus(); }, 60);
|
||||
}
|
||||
function close() { opened = false; root.classList.remove("msa-open"); panel.setAttribute("aria-hidden", "true"); launch.setAttribute("aria-expanded", "false"); launch.focus(); }
|
||||
function submit() { var q = input.value.trim(); if (!q) return; input.value = ""; answer(q); }
|
||||
|
||||
function build() {
|
||||
root = document.createElement("div");
|
||||
root.className = "msa-root"; root.setAttribute("data-msa", "1");
|
||||
// avoid overlapping the "Become a member" floating CTA on that page
|
||||
if (document.querySelector(".mm-sticky-cta")) root.classList.add("msa-left");
|
||||
root.innerHTML =
|
||||
'<button class="msa-launch" aria-expanded="false" aria-controls="msa-panel" aria-label="' + esc(UI.open) + '">' +
|
||||
'<i class="fas fa-comment-dots" aria-hidden="true"></i><span>' + esc(UI.open) + '</span></button>' +
|
||||
'<section class="msa-panel" id="msa-panel" role="dialog" aria-label="' + esc(UI.title) + '" aria-hidden="true">' +
|
||||
'<header class="msa-head"><span>' + esc(UI.title) + '</span>' +
|
||||
'<header class="msa-head"><span><i class="fas fa-graduation-cap" aria-hidden="true"></i> ' + esc(UI.title) + '</span>' +
|
||||
'<button class="msa-x" aria-label="' + esc(UI.close) + '">×</button></header>' +
|
||||
'<div class="msa-log" role="log" aria-live="polite"></div>' +
|
||||
'<form class="msa-form"><input class="msa-input" type="text" autocomplete="off" ' +
|
||||
'placeholder="' + esc(UI.placeholder) + '" aria-label="' + esc(UI.placeholder) + '">' +
|
||||
'<button class="msa-send" type="submit" aria-label="' + esc(UI.send) + '"><i class="fas fa-paper-plane" aria-hidden="true"></i></button>' +
|
||||
'</form>' +
|
||||
'<p class="msa-foot">' + esc(UI.disclaimer) + '</p>' +
|
||||
'</section>';
|
||||
'<form class="msa-form"><input class="msa-input" type="text" autocomplete="off" placeholder="' + esc(UI.placeholder) + '" aria-label="' + esc(UI.placeholder) + '">' +
|
||||
'<button class="msa-send" type="submit" aria-label="' + esc(UI.send) + '"><i class="fas fa-paper-plane" aria-hidden="true"></i></button></form>' +
|
||||
'<p class="msa-foot">' + esc(UI.disclaimer) + "</p>" +
|
||||
"</section>";
|
||||
document.body.appendChild(root);
|
||||
panel = root.querySelector(".msa-panel");
|
||||
log = root.querySelector(".msa-log");
|
||||
input = root.querySelector(".msa-input");
|
||||
launch = root.querySelector(".msa-launch");
|
||||
panel = root.querySelector(".msa-panel"); log = root.querySelector(".msa-log");
|
||||
input = root.querySelector(".msa-input"); launch = root.querySelector(".msa-launch");
|
||||
launch.addEventListener("click", function () { opened ? close() : open(); });
|
||||
root.querySelector(".msa-x").addEventListener("click", close);
|
||||
root.querySelector(".msa-form").addEventListener("submit", function (e) { e.preventDefault(); submit(); });
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ assistant/index-<lang>.json (loaded lazily in the browser, no API needed)."""
|
|||
import os, re, json, glob
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
ROOT = r"C:\Coding\msosorg.com"
|
||||
# Repo root = parent of this script's tools/ folder. Works locally and in CI
|
||||
# (override with SITE_ROOT env var if ever needed).
|
||||
ROOT = os.environ.get("SITE_ROOT") or os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.chdir(ROOT)
|
||||
OUT = os.path.join(ROOT, "assistant")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
|
|
|||
Loading…
Reference in New Issue