Spaces:
Running on Zero
Fix the chat box being off-screen, the scroll traps, and a sanitiser bug eating hyphens
Browse filesHi — these are fixes on top of your LangChain/RAG work (`9c653b9`), found while
reviewing this Space. Nothing of yours is reverted: `langchain_llm.py`, the
semantic search and the prompt changes all stay as you wrote them.
**One bug is live right now.** In `engine._CJK_RE`, the private-use-area range
`'\ue000-\uf8ff'` was flattened to a bare `'-'` somewhere in the web editor. The
class no longer strips PUA characters, and it *does* strip a literal ASCII
hyphen from every model reply — `"التسجيل المباشر - المفاضلة"` comes back
without its dash. Restored.
**`build_llm` would break the Streamlit build.** It imports `llm.langchain_llm`
unconditionally, but `requirements-deploy.txt` does not carry LangChain, so
every chat answer there would silently drop to the deterministic fallback.
LangChain is added to that manifest, and an `ImportError` now falls back to the
provider SDKs — the behaviour `requirements.txt` already describes in its own
comment ("cloud_llm.py is kept only as a documented fallback").
**Four tests were patching a dead path.** `test_build_llm_*` still monkeypatched
`llm.cloud_llm.CloudLLM`, which `build_llm` no longer calls. They patch the
LangChain path now; the contract under test is unchanged, since
`langchain_llm.PROVIDER_PRECEDENCE` is the same list. 148 tests pass with
LangChain installed and without it.
**The chat box was unreachable.** It is the last child of `#kalim-main`, so
`position: sticky; bottom: 3rem` had no room to lift it — measured, it sat
748px below the fold on a 1440x900 desktop and 873px below on a 390px phone. A
student saw ten result cards and no way to ask anything. It is `fixed` now,
tracking the main column. The footer also wraps to two lines at 390px (54px),
while the offsets guarding it were a flat 3rem and, on phones, 1.2rem — less
than the thing they were clearing. A small observer measures the footer, the
column and the composer into CSS variables instead of hardcoding them.
**Two scroll traps.** The conversation auto-scrolled to the bottom on any
mutation, so scrolling up to re-read something pulled you straight back down;
it now sticks only when you are already at the bottom, and a question you typed
re-sticks it. And the pane carried `overscroll-behavior: contain` while being
sized against the whole window, so on anything shorter than 900px the first
wizard question sat below the fold with no way to scroll to it — the pointer
was over a pane that would not chain its scroll.
**The shortlist reads in order.** It was a `gr.render` panel pinned below the
chat, so an answer to a question asked after the list appeared *above* the
list. Each batch is posted as a chat message of `<details>` cards now, so
"show more" appends underneath and so does every later answer. Opening a card
is still a client-side toggle. This needs the chatbot to render HTML, so
escaping moved into `_bot`/`_user` — model replies are escaped, only markup the
file writes is live.
**Startup.** The semantic index was built at import, before `gr.Blocks` was
defined, so the Space served nothing at all until the sentence-transformers
model had downloaded and all 95 majors were embedded — on every ZeroGPU
restart. It builds on a background thread now; the chat works without it.
`CLAUDE.md` records the layout constraints, so the next editing session does
not undo the measured offsets by replacing them with rem values again.
Happy to split this into smaller PRs if you would rather take it in pieces.
- CLAUDE.md +29 -0
- app_gradio.py +531 -82
- config/prompts.py +1 -1
- config/settings.py +1 -1
- requirements-deploy.txt +7 -1
- requirements.txt +1 -1
- src/core/engine.py +52 -3
- tests/test_engine.py +23 -7
|
@@ -86,6 +86,17 @@ twice, so a client rewording would have landed in one build only.
|
|
| 86 |
option lists come from `SECONDARY_TRACK_NAMES` / `TEACHING_LANGUAGES` for the
|
| 87 |
same reason.
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
`engine.free_chat_reply` returns a `ChatTurn(reply, visible)`: the shells store
|
| 90 |
`turn.visible` as the new on-screen window and must never re-derive the paging
|
| 91 |
decision themselves. The Gradio `chat()` handler used to re-run the
|
|
@@ -141,6 +152,24 @@ opening `<div>` never wraps the widgets that follow. Use
|
|
| 141 |
until the next rerun.** The welcome message is seeded in `init_state()` for this
|
| 142 |
reason.
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
## RTL
|
| 145 |
|
| 146 |
The app shell stays LTR (`[data-testid="stAppViewContainer"] { direction: ltr }`)
|
|
|
|
| 86 |
option lists come from `SECONDARY_TRACK_NAMES` / `TEACHING_LANGUAGES` for the
|
| 87 |
same reason.
|
| 88 |
|
| 89 |
+
In the Gradio build the shortlist is **part of the conversation**, not a panel
|
| 90 |
+
under it: `_cards_message` renders each batch of results as `<details>` cards
|
| 91 |
+
posted as a chat message, so a question asked after the list gets its answer
|
| 92 |
+
*below* the list. It used to be a `gr.render` panel pinned beneath the chat,
|
| 93 |
+
which meant every later answer appeared above the list the student had just
|
| 94 |
+
been given. Two consequences to keep in mind: the chatbot runs with
|
| 95 |
+
`sanitize_html=False`, so `_bot`/`_user` escape everything that is not markup
|
| 96 |
+
this file wrote (model replies above all), and Gradio groups consecutive
|
| 97 |
+
assistant messages into one bubble, so a card message cannot be styled as if
|
| 98 |
+
it owned its bubble — the cards take the surface tint instead.
|
| 99 |
+
|
| 100 |
`engine.free_chat_reply` returns a `ChatTurn(reply, visible)`: the shells store
|
| 101 |
`turn.visible` as the new on-screen window and must never re-derive the paging
|
| 102 |
decision themselves. The Gradio `chat()` handler used to re-run the
|
|
|
|
| 152 |
until the next rerun.** The welcome message is seeded in `init_state()` for this
|
| 153 |
reason.
|
| 154 |
|
| 155 |
+
## Gradio layout constraints
|
| 156 |
+
|
| 157 |
+
Three sizes on that page are not knowable to CSS, and every hardcoded offset
|
| 158 |
+
that assumed them was wrong somewhere: the footer wraps to two lines below
|
| 159 |
+
~700px, the main column shifts when the sidebar opens, and the composer does
|
| 160 |
+
not exist until results do. A layout observer in `FORCE_LIGHT` measures them
|
| 161 |
+
into `--kalim-footer-h`, `--kalim-dock-left/-width/-h` and `--kalim-chat-max`.
|
| 162 |
+
Do not replace those with rem values.
|
| 163 |
+
|
| 164 |
+
The composer is `position: fixed`, not `sticky`. It was sticky, which is a
|
| 165 |
+
no-op for it: the textbox is the last child of `#kalim-main`, so its flow
|
| 166 |
+
position already sits at the bottom of its containing block and sticky has no
|
| 167 |
+
room to lift it — measured, it sat 748px below the fold on a 1440x900 desktop.
|
| 168 |
+
|
| 169 |
+
A `gr.HTML` whose content is `position: fixed` still leaves its Gradio wrappers
|
| 170 |
+
in flow. The footer's wrappers added ~52px of empty page, which is why they are
|
| 171 |
+
collapsed explicitly.
|
| 172 |
+
|
| 173 |
## RTL
|
| 174 |
|
| 175 |
The app shell stays LTR (`[data-testid="stAppViewContainer"] { direction: ltr }`)
|
|
@@ -21,6 +21,9 @@ from pathlib import Path
|
|
| 21 |
from functools import lru_cache
|
| 22 |
import os
|
| 23 |
import sys
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
# Ensure project root and src directory are in Python path
|
| 26 |
_PROJECT_ROOT = str(Path(__file__).parent.resolve())
|
|
@@ -73,16 +76,28 @@ from core import engine
|
|
| 73 |
# `python -m src.rag.vectorstore` manually, so this runs it once per restart
|
| 74 |
# instead. Guarded so a missing optional dependency degrades to no semantic
|
| 75 |
# search rather than crashing the whole app.
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
NO_TRACK = "غير محدد"
|
| 88 |
LANG_FLAGS = {"العربية": "🇱🇧", "الانكليزية": "🇬🇧", "الفرنسية": "🇫🇷"}
|
|
@@ -113,11 +128,86 @@ def _initial_state() -> dict:
|
|
| 113 |
|
| 114 |
|
| 115 |
def _initial_history() -> list:
|
| 116 |
-
return [{'role': 'assistant', 'content': WELCOME_MESSAGE}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
def _bot(history, text):
|
| 120 |
-
history.append({'role': 'assistant', 'content': text})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
return history
|
| 122 |
|
| 123 |
|
|
@@ -158,9 +248,11 @@ def apply_search(holland_sel, track_sel, lang_sel, gov_sel, history, state):
|
|
| 158 |
"، ".join(govs) or None,
|
| 159 |
]))
|
| 160 |
history = _initial_history()
|
| 161 |
-
|
| 162 |
reply = engine.results_intro(results, codes, interactive_cards=False)
|
| 163 |
-
|
|
|
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
def show_more(history, state):
|
|
@@ -173,14 +265,15 @@ def show_more(history, state):
|
|
| 173 |
|
| 174 |
nxt = results[len(visible):len(visible) + engine.PAGE_SIZE]
|
| 175 |
shown = len(visible) + len(nxt)
|
| 176 |
-
# The cards below re-render from `state`, so the new majors simply appear;
|
| 177 |
-
# the chat only narrates what happened.
|
| 178 |
reply = (
|
| 179 |
f"أضفت التخصصات من {len(visible) + 1} إلى {shown} "
|
| 180 |
f"(من أصل {len(results)}) إلى القائمة في الأسفل."
|
| 181 |
)
|
|
|
|
|
|
|
|
|
|
| 182 |
state = {**state, 'visible': results[:shown]}
|
| 183 |
-
return
|
| 184 |
|
| 185 |
|
| 186 |
def compare(history, state):
|
|
@@ -202,7 +295,7 @@ def chat(message, history, state):
|
|
| 202 |
if not message:
|
| 203 |
return "", history, state
|
| 204 |
|
| 205 |
-
|
| 206 |
visible, results = state['visible'], state['results']
|
| 207 |
# Past the per-session budget the answer still comes, from the data rather
|
| 208 |
# than the model. Never an error — see engine.MAX_LLM_TURNS_PER_SESSION.
|
|
@@ -216,8 +309,13 @@ def chat(message, history, state):
|
|
| 216 |
llm=None if over_budget else get_llm(),
|
| 217 |
)
|
| 218 |
# The engine owns the paging decision; the shell only stores its result.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
state = {**state, 'visible': turn.visible}
|
| 220 |
-
return "",
|
| 221 |
|
| 222 |
|
| 223 |
# ---------------------------------------------------------------------------
|
|
@@ -262,7 +360,7 @@ def _sync(state):
|
|
| 262 |
|
| 263 |
|
| 264 |
def _user(history, text):
|
| 265 |
-
history.append({'role': 'user', 'content': text})
|
| 266 |
return history
|
| 267 |
|
| 268 |
|
|
@@ -353,6 +451,7 @@ def _finish_wizard(history, state, govs, dists):
|
|
| 353 |
'results': results, 'visible': results[:engine.PAGE_SIZE]}
|
| 354 |
reply = engine.results_intro(results, state['holland_codes'], interactive_cards=False)
|
| 355 |
_bot(history, reply)
|
|
|
|
| 356 |
return (history, state, *_vis('results'), *_sync(state))
|
| 357 |
|
| 358 |
|
|
@@ -407,16 +506,28 @@ CSS = """
|
|
| 407 |
}
|
| 408 |
|
| 409 |
body {
|
| 410 |
-
/* Atmospheric page ground: airy blue-white with a navy glow (top), a warm
|
| 411 |
-
cedar glow (bottom) and a faint dot grid — cards stay white on top.
|
| 412 |
-
CSS-only: the HF Space remote rejects plain-git binary files. */
|
| 413 |
background-color: #fbfcfe !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
background-image:
|
| 415 |
radial-gradient(1200px 600px at 85% -10%, rgba(42, 82, 152, 0.12), transparent 60%),
|
| 416 |
radial-gradient(1000px 520px at -10% 108%, rgba(179, 84, 30, 0.08), transparent 55%),
|
| 417 |
-
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26'%3E%3Ccircle cx='1.4' cy='1.4' r='1.1' fill='%232a5298' fill-opacity='0.06'/%3E%3C/svg%3E")
|
| 418 |
-
background-attachment: fixed, fixed, fixed;
|
| 419 |
-
color: var(--text);
|
| 420 |
}
|
| 421 |
/* <gradio-app> and the container both paint white by default, hiding the
|
| 422 |
body's texture — make the whole stack transparent down to body. */
|
|
@@ -430,6 +541,35 @@ gradio-app, .gradio-container {
|
|
| 430 |
}
|
| 431 |
.gradio-container *:not([class*="icon"]) { font-family: var(--kalim-font) !important; }
|
| 432 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
/* Platform chrome: Gradio's own footer, and the owner pill Hugging Face
|
| 434 |
injects into this page's body on direct .hf.space visits. */
|
| 435 |
footer { display: none !important; }
|
|
@@ -617,34 +757,81 @@ ul.options .item, ul.options li { text-align: right; }
|
|
| 617 |
outline-offset: 2px !important;
|
| 618 |
}
|
| 619 |
|
| 620 |
-
/* Result cards — the
|
| 621 |
-
|
| 622 |
-
|
|
|
|
|
|
|
| 623 |
.kalim-major-card {
|
| 624 |
-
border: 1px solid var(--line)
|
| 625 |
-
border-radius: var(--radius)
|
| 626 |
-
background: var(--bg)
|
| 627 |
-
box-shadow: var(--kalim-shadow)
|
| 628 |
-
margin: 0 0 0.5rem 0
|
| 629 |
overflow: hidden;
|
|
|
|
| 630 |
}
|
| 631 |
-
.kalim-major-card
|
| 632 |
-
.kalim-major-card
|
| 633 |
-
direction: rtl
|
| 634 |
-
text-align: right
|
| 635 |
-
font-weight: 600
|
| 636 |
-
color: var(--ink-700)
|
| 637 |
-
padding: 0.7rem 0.9rem
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
}
|
|
|
|
| 639 |
.kalim-major-card:hover {
|
| 640 |
-
border-color: var(--line-strong)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
}
|
| 642 |
.kalim-major-details {
|
| 643 |
-
direction: rtl
|
| 644 |
-
text-align: right
|
| 645 |
-
padding: 0 0.9rem 0.
|
|
|
|
|
|
|
| 646 |
}
|
| 647 |
.kalim-major-details p { margin: 0.35rem 0 !important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
|
| 649 |
/* ===== Chat ===== */
|
| 650 |
/* Styled as a modern messenger: centered column, avatars, distinct bubbles,
|
|
@@ -653,6 +840,23 @@ ul.options .item, ul.options li { text-align: right; }
|
|
| 653 |
further away with every answer, so the shortlist appeared to jump after each
|
| 654 |
new reply instead of staying put. */
|
| 655 |
#kalim-main { max-width: 880px; margin: 0 auto; width: 100%; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
#kalim-chat {
|
| 657 |
border: none !important;
|
| 658 |
box-shadow: none !important;
|
|
@@ -669,9 +873,25 @@ ul.options .item, ul.options li { text-align: right; }
|
|
| 669 |
#kalim-chat .bubble-wrap {
|
| 670 |
background: transparent !important;
|
| 671 |
height: auto !important;
|
| 672 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 673 |
overflow-y: auto !important;
|
| 674 |
-
overscroll-behavior: contain
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
}
|
| 676 |
#kalim-chat .message-wrap { gap: 1.05rem !important; }
|
| 677 |
/* No chat chrome: the clear-history icon is the last one buttons=[] leaves. */
|
|
@@ -775,9 +995,20 @@ ul.options .item, ul.options li { text-align: right; }
|
|
| 775 |
border: none !important;
|
| 776 |
box-shadow: none !important;
|
| 777 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
#kalim-input {
|
| 779 |
-
position:
|
| 780 |
-
bottom:
|
|
|
|
|
|
|
| 781 |
z-index: 900;
|
| 782 |
background: var(--bg);
|
| 783 |
border: 1px solid var(--line-strong) !important;
|
|
@@ -785,6 +1016,20 @@ ul.options .item, ul.options li { text-align: right; }
|
|
| 785 |
box-shadow: 0 6px 24px rgba(16, 36, 63, 0.10);
|
| 786 |
padding: 0.15rem 0.4rem !important;
|
| 787 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
#kalim-input textarea {
|
| 789 |
direction: rtl !important; text-align: right !important;
|
| 790 |
font-size: 16px !important; /* keeps iOS Safari from zooming on focus */
|
|
@@ -813,6 +1058,24 @@ ul.options .item, ul.options li { text-align: right; }
|
|
| 813 |
#kalim-input button * { color: #fff !important; fill: #fff !important; }
|
| 814 |
|
| 815 |
/* ===== Footer — mirrors .kalim-footer: fixed to the bottom edge ===== */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 816 |
#kalim-footer {
|
| 817 |
position: fixed;
|
| 818 |
bottom: 0; left: 0; right: 0;
|
|
@@ -825,8 +1088,14 @@ ul.options .item, ul.options li { text-align: right; }
|
|
| 825 |
margin: 0;
|
| 826 |
z-index: 998;
|
| 827 |
}
|
| 828 |
-
/* Keep the last
|
| 829 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 830 |
|
| 831 |
@media (max-width: 1024px) {
|
| 832 |
#kalim-header h1 { font-size: 1.45rem; }
|
|
@@ -842,8 +1111,8 @@ ul.options .item, ul.options li { text-align: right; }
|
|
| 842 |
#kalim-chat .message-row .message,
|
| 843 |
#kalim-chat .message { max-width: 100% !important; }
|
| 844 |
#kalim-chat .message-row { gap: 0.4rem !important; }
|
| 845 |
-
/*
|
| 846 |
-
|
| 847 |
|
| 848 |
/* Hide Kalim's avatar on phones: the 35px circle plus its gutter costs
|
| 849 |
more width than it communicates, and Kalim's turns are the long ones
|
|
@@ -861,8 +1130,12 @@ ul.options .item, ul.options li { text-align: right; }
|
|
| 861 |
margin-right: var(--spacing-xl) !important;
|
| 862 |
}
|
| 863 |
#kalim-actions { flex-wrap: wrap; }
|
| 864 |
-
|
| 865 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 866 |
}
|
| 867 |
"""
|
| 868 |
|
|
@@ -908,27 +1181,216 @@ FORCE_LIGHT = """
|
|
| 908 |
})();
|
| 909 |
</script>
|
| 910 |
<script>
|
| 911 |
-
/*
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
(function () {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 916 |
let pending = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
const schedule = () => {
|
| 918 |
clearTimeout(pending);
|
| 919 |
pending = setTimeout(() => {
|
| 920 |
const chat = document.querySelector('#kalim-chat');
|
| 921 |
const scroller = chat && chat.querySelector('.bubble-wrap');
|
| 922 |
-
if (scroller)
|
| 923 |
-
|
| 924 |
-
|
|
|
|
| 925 |
}, 160);
|
| 926 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 927 |
const attach = () => {
|
| 928 |
const chat = document.querySelector('#kalim-chat');
|
| 929 |
if (!chat) { setTimeout(attach, 300); return; }
|
|
|
|
|
|
|
| 930 |
new MutationObserver(schedule).observe(chat, { childList: true, subtree: true });
|
| 931 |
};
|
|
|
|
| 932 |
if (document.readyState === 'loading') {
|
| 933 |
document.addEventListener('DOMContentLoaded', attach);
|
| 934 |
} else {
|
|
@@ -999,6 +1461,12 @@ with gr.Blocks(title=PAGE_TITLE) as demo:
|
|
| 999 |
show_label=False, buttons=[],
|
| 1000 |
avatar_images=("assets/avatar-student.svg", "assets/avatar-kalim.svg"),
|
| 1001 |
elem_id="kalim-chat",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1002 |
)
|
| 1003 |
|
| 1004 |
# Wizard steps — one visible at a time, in place of the chat controls.
|
|
@@ -1040,25 +1508,6 @@ with gr.Blocks(title=PAGE_TITLE) as demo:
|
|
| 1040 |
confirm_loc_btn = gr.Button("تأكيد", variant="primary")
|
| 1041 |
all_locs_btn = gr.Button("جميع المحافظات")
|
| 1042 |
|
| 1043 |
-
# Clickable result cards, the Streamlit build's expander list ported
|
| 1044 |
-
# over: the chat narrates, this panel is the browsable shortlist.
|
| 1045 |
-
# gr.render rebuilds it whenever `state` changes, so "عرض تخصصات
|
| 1046 |
-
# إضافية" simply grows the window and more cards appear — no fixed
|
| 1047 |
-
# pool of components to keep in sync.
|
| 1048 |
-
@gr.render(inputs=state)
|
| 1049 |
-
def render_result_cards(current):
|
| 1050 |
-
visible = (current or {}).get('visible') or []
|
| 1051 |
-
for position, result in enumerate(visible, 1):
|
| 1052 |
-
major = result.get('major', {})
|
| 1053 |
-
header = (
|
| 1054 |
-
f"{position}. {major.get('name_ar', '')} — "
|
| 1055 |
-
f"{major.get('faculty_ar', '')} · "
|
| 1056 |
-
f"{result.get('holland_score', 0)}% مطابقة"
|
| 1057 |
-
)
|
| 1058 |
-
with gr.Accordion(header, open=False, elem_classes="kalim-major-card"):
|
| 1059 |
-
gr.Markdown(engine.format_major_details(major),
|
| 1060 |
-
elem_classes="kalim-major-details")
|
| 1061 |
-
|
| 1062 |
with gr.Row(elem_id="kalim-actions", visible=False) as actions_row:
|
| 1063 |
more_btn = gr.Button("عرض تخصصات إضافية ⬇️", size="sm")
|
| 1064 |
compare_btn = gr.Button("📊 قارن التخصصات", size="sm")
|
|
@@ -1111,4 +1560,4 @@ with gr.Blocks(title=PAGE_TITLE) as demo:
|
|
| 1111 |
if __name__ == "__main__":
|
| 1112 |
# Gradio 6 takes presentation options at launch time. Hugging Face Spaces
|
| 1113 |
# runs this file as __main__, so this path serves the Space too.
|
| 1114 |
-
demo.launch(theme=theme, css=CSS, head=FORCE_LIGHT)
|
|
|
|
| 21 |
from functools import lru_cache
|
| 22 |
import os
|
| 23 |
import sys
|
| 24 |
+
import threading
|
| 25 |
+
import re
|
| 26 |
+
from html import escape
|
| 27 |
|
| 28 |
# Ensure project root and src directory are in Python path
|
| 29 |
_PROJECT_ROOT = str(Path(__file__).parent.resolve())
|
|
|
|
| 76 |
# `python -m src.rag.vectorstore` manually, so this runs it once per restart
|
| 77 |
# instead. Guarded so a missing optional dependency degrades to no semantic
|
| 78 |
# search rather than crashing the whole app.
|
| 79 |
+
#
|
| 80 |
+
# On a background thread, not at import: building it inline downloads the
|
| 81 |
+
# sentence-transformers model and embeds all 95 majors *before* gr.Blocks is
|
| 82 |
+
# even defined, so the Space served nothing at all until it finished — the
|
| 83 |
+
# whole cold start, on every ZeroGPU restart. The chat works without it
|
| 84 |
+
# (engine.semantic_majors_mentioned only runs when a question names no major
|
| 85 |
+
# literally, and returns nothing while the collection is empty), so the index
|
| 86 |
+
# can finish after the UI is already up.
|
| 87 |
+
def _build_semantic_index():
|
| 88 |
+
try:
|
| 89 |
+
from rag.vectorstore import get_vectorstore
|
| 90 |
+
|
| 91 |
+
vs = get_vectorstore()
|
| 92 |
+
if vs.collection.count() == 0:
|
| 93 |
+
print("Building semantic search index (first run)...")
|
| 94 |
+
vs.build_index()
|
| 95 |
+
print(f"Index built: {vs.collection.count()} documents")
|
| 96 |
+
except Exception as exc:
|
| 97 |
+
print(f"Semantic index not built (optional feature disabled): {exc}")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
threading.Thread(target=_build_semantic_index, name="kalim-index", daemon=True).start()
|
| 101 |
|
| 102 |
NO_TRACK = "غير محدد"
|
| 103 |
LANG_FLAGS = {"العربية": "🇱🇧", "الانكليزية": "🇬🇧", "الفرنسية": "🇫🇷"}
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
def _initial_history() -> list:
|
| 131 |
+
return [{'role': 'assistant', 'content': _as_text(WELCOME_MESSAGE)}]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# gr.Chatbot renders raw HTML for us (sanitize_html=False), which makes
|
| 135 |
+
# escaping this shell's job. Everything that is not markup written in this file
|
| 136 |
+
# goes through here first: dataset text, the engine's wording, and above all
|
| 137 |
+
# model replies, which are the one input a student could steer. Markdown is
|
| 138 |
+
# unaffected — its syntax does not use these three characters.
|
| 139 |
+
def _as_text(content: str) -> str:
|
| 140 |
+
return escape(str(content), quote=False)
|
| 141 |
|
| 142 |
|
| 143 |
def _bot(history, text):
|
| 144 |
+
history.append({'role': 'assistant', 'content': _as_text(text)})
|
| 145 |
+
return history
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# --- The shortlist, as part of the conversation ------------------------------
|
| 149 |
+
# The cards used to be a gr.render panel pinned below the chat, which meant a
|
| 150 |
+
# later answer appeared *above* the list the student had just been given — the
|
| 151 |
+
# newest thing on screen was not the newest thing said. They are chat content
|
| 152 |
+
# now: each batch is posted as its own message, so the conversation reads in
|
| 153 |
+
# the order it happened and anything asked afterwards lands underneath.
|
| 154 |
+
#
|
| 155 |
+
# <details>/<summary> rather than gr.Accordion: only a component can go in the
|
| 156 |
+
# component tree, and this has to live inside a message. Collapsing is native
|
| 157 |
+
# to the element, so it stays a click with no round trip, exactly as before.
|
| 158 |
+
# gr.Chatbot(allow_tags=...) lets these two tags through its sanitiser; every
|
| 159 |
+
# other tag, including anything a model might emit, is still stripped.
|
| 160 |
+
_MD_BOLD = re.compile(r'\*\*(.+?)\*\*', re.S)
|
| 161 |
+
_MD_ITALIC = re.compile(r'(?<!\*)\*([^*]+?)\*(?!\*)', re.S)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _mini_markdown(text: str) -> str:
|
| 165 |
+
"""The little markdown format_major_details emits, as HTML.
|
| 166 |
+
|
| 167 |
+
Only bold, italics and paragraph breaks — everything is escaped first, so
|
| 168 |
+
dataset text cannot inject markup.
|
| 169 |
+
"""
|
| 170 |
+
out = []
|
| 171 |
+
for para in escape(text).split("\n\n"):
|
| 172 |
+
para = para.strip()
|
| 173 |
+
if not para:
|
| 174 |
+
continue
|
| 175 |
+
para = _MD_BOLD.sub(r'<strong>\1</strong>', para)
|
| 176 |
+
para = _MD_ITALIC.sub(r'<em>\1</em>', para)
|
| 177 |
+
out.append(f'<p>{para}</p>')
|
| 178 |
+
return "".join(out)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _cards_message(results, start: int = 0) -> str:
|
| 182 |
+
"""Collapsible cards for results[start:], numbered from their real rank."""
|
| 183 |
+
cards = []
|
| 184 |
+
for position, result in enumerate(results[start:], start + 1):
|
| 185 |
+
major = result.get('major', {})
|
| 186 |
+
header = (
|
| 187 |
+
f"{position}. {major.get('name_ar', '')} — "
|
| 188 |
+
f"{major.get('faculty_ar', '')} · "
|
| 189 |
+
f"{result.get('holland_score', 0)}% مطابقة"
|
| 190 |
+
)
|
| 191 |
+
cards.append(
|
| 192 |
+
'<details class="kalim-major-card">'
|
| 193 |
+
f'<summary>{escape(header)}</summary>'
|
| 194 |
+
f'<div class="kalim-major-details">'
|
| 195 |
+
f'{_mini_markdown(engine.format_major_details(major))}</div>'
|
| 196 |
+
'</details>'
|
| 197 |
+
)
|
| 198 |
+
return '<div class="kalim-cards">' + "".join(cards) + '</div>'
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _bot_cards(history, results, start: int = 0):
|
| 202 |
+
"""Post one batch of cards, if there is anything new to post.
|
| 203 |
+
|
| 204 |
+
Appends the markup directly rather than through _bot: this is the one
|
| 205 |
+
message whose HTML is meant to be live, and its own text was escaped
|
| 206 |
+
field by field in _cards_message.
|
| 207 |
+
"""
|
| 208 |
+
if len(results) > start:
|
| 209 |
+
history.append({'role': 'assistant',
|
| 210 |
+
'content': _cards_message(results, start)})
|
| 211 |
return history
|
| 212 |
|
| 213 |
|
|
|
|
| 248 |
"، ".join(govs) or None,
|
| 249 |
]))
|
| 250 |
history = _initial_history()
|
| 251 |
+
_user(history, f"بحث جديد: {summary}")
|
| 252 |
reply = engine.results_intro(results, codes, interactive_cards=False)
|
| 253 |
+
_bot(history, reply)
|
| 254 |
+
_bot_cards(history, state['visible'])
|
| 255 |
+
return (history, state, *_vis('results'))
|
| 256 |
|
| 257 |
|
| 258 |
def show_more(history, state):
|
|
|
|
| 265 |
|
| 266 |
nxt = results[len(visible):len(visible) + engine.PAGE_SIZE]
|
| 267 |
shown = len(visible) + len(nxt)
|
|
|
|
|
|
|
| 268 |
reply = (
|
| 269 |
f"أضفت التخصصات من {len(visible) + 1} إلى {shown} "
|
| 270 |
f"(من أصل {len(results)}) إلى القائمة في الأسفل."
|
| 271 |
)
|
| 272 |
+
_bot(history, reply)
|
| 273 |
+
# Only the new batch, numbered from where the last one stopped.
|
| 274 |
+
_bot_cards(history, results[:shown], start=len(visible))
|
| 275 |
state = {**state, 'visible': results[:shown]}
|
| 276 |
+
return history, state
|
| 277 |
|
| 278 |
|
| 279 |
def compare(history, state):
|
|
|
|
| 295 |
if not message:
|
| 296 |
return "", history, state
|
| 297 |
|
| 298 |
+
_user(history, message)
|
| 299 |
visible, results = state['visible'], state['results']
|
| 300 |
# Past the per-session budget the answer still comes, from the data rather
|
| 301 |
# than the model. Never an error — see engine.MAX_LLM_TURNS_PER_SESSION.
|
|
|
|
| 309 |
llm=None if over_budget else get_llm(),
|
| 310 |
)
|
| 311 |
# The engine owns the paging decision; the shell only stores its result.
|
| 312 |
+
_bot(history, turn.reply)
|
| 313 |
+
# A paged-in answer widens the window — those majors are new to the
|
| 314 |
+
# student, so they follow the reply as their own batch of cards.
|
| 315 |
+
if len(turn.visible) > len(visible):
|
| 316 |
+
_bot_cards(history, turn.visible, start=len(visible))
|
| 317 |
state = {**state, 'visible': turn.visible}
|
| 318 |
+
return "", history, state
|
| 319 |
|
| 320 |
|
| 321 |
# ---------------------------------------------------------------------------
|
|
|
|
| 360 |
|
| 361 |
|
| 362 |
def _user(history, text):
|
| 363 |
+
history.append({'role': 'user', 'content': _as_text(text)})
|
| 364 |
return history
|
| 365 |
|
| 366 |
|
|
|
|
| 451 |
'results': results, 'visible': results[:engine.PAGE_SIZE]}
|
| 452 |
reply = engine.results_intro(results, state['holland_codes'], interactive_cards=False)
|
| 453 |
_bot(history, reply)
|
| 454 |
+
_bot_cards(history, state['visible'])
|
| 455 |
return (history, state, *_vis('results'), *_sync(state))
|
| 456 |
|
| 457 |
|
|
|
|
| 506 |
}
|
| 507 |
|
| 508 |
body {
|
|
|
|
|
|
|
|
|
|
| 509 |
background-color: #fbfcfe !important;
|
| 510 |
+
color: var(--text);
|
| 511 |
+
}
|
| 512 |
+
/* Atmospheric page ground: airy blue-white with a navy glow (top), a warm
|
| 513 |
+
cedar glow (bottom) and a faint dot grid — cards stay white on top.
|
| 514 |
+
CSS-only: the HF Space remote rejects plain-git binary files.
|
| 515 |
+
Painted on a fixed pseudo-element rather than as three
|
| 516 |
+
`background-attachment: fixed` layers on <body>: attachment:fixed forces the
|
| 517 |
+
browser to repaint the gradients and the dot tile against new scroll offsets
|
| 518 |
+
on every frame, which is the one thing on this page that costs real work
|
| 519 |
+
while scrolling. As its own composited layer it is painted once. */
|
| 520 |
+
body::before {
|
| 521 |
+
content: "";
|
| 522 |
+
position: fixed;
|
| 523 |
+
inset: 0;
|
| 524 |
+
z-index: -1;
|
| 525 |
+
pointer-events: none;
|
| 526 |
+
background-color: #fbfcfe;
|
| 527 |
background-image:
|
| 528 |
radial-gradient(1200px 600px at 85% -10%, rgba(42, 82, 152, 0.12), transparent 60%),
|
| 529 |
radial-gradient(1000px 520px at -10% 108%, rgba(179, 84, 30, 0.08), transparent 55%),
|
| 530 |
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26'%3E%3Ccircle cx='1.4' cy='1.4' r='1.1' fill='%232a5298' fill-opacity='0.06'/%3E%3C/svg%3E");
|
|
|
|
|
|
|
| 531 |
}
|
| 532 |
/* <gradio-app> and the container both paint white by default, hiding the
|
| 533 |
body's texture — make the whole stack transparent down to body. */
|
|
|
|
| 541 |
}
|
| 542 |
.gradio-container *:not([class*="icon"]) { font-family: var(--kalim-font) !important; }
|
| 543 |
|
| 544 |
+
/* ===== Scrollbars =====
|
| 545 |
+
The app legitimately has more than one scroll region (the page, the
|
| 546 |
+
conversation, the sidebar, a wide result table), and each arrived with the
|
| 547 |
+
platform's default chrome — a stack of grey rails that read as clutter.
|
| 548 |
+
One quiet treatment for all of them: thin, no track, thumb only. */
|
| 549 |
+
* {
|
| 550 |
+
scrollbar-width: thin;
|
| 551 |
+
scrollbar-color: var(--line-strong) transparent;
|
| 552 |
+
}
|
| 553 |
+
*::-webkit-scrollbar { width: 9px; height: 9px; }
|
| 554 |
+
*::-webkit-scrollbar-track { background: transparent; }
|
| 555 |
+
*::-webkit-scrollbar-corner { background: transparent; }
|
| 556 |
+
*::-webkit-scrollbar-thumb {
|
| 557 |
+
background: var(--line-strong);
|
| 558 |
+
background-clip: content-box;
|
| 559 |
+
border: 2px solid transparent;
|
| 560 |
+
border-radius: 999px;
|
| 561 |
+
}
|
| 562 |
+
*::-webkit-scrollbar-thumb:hover {
|
| 563 |
+
background: var(--ink-300);
|
| 564 |
+
background-clip: content-box;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
/* Gradio wraps several blocks in scroll containers that never actually
|
| 568 |
+
overflow — they contribute an inert rail and nothing else. */
|
| 569 |
+
#kalim-main .form, #kalim-main > .block, .kalim-major-details {
|
| 570 |
+
overflow: visible !important;
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
/* Platform chrome: Gradio's own footer, and the owner pill Hugging Face
|
| 574 |
injects into this page's body on direct .hf.space visits. */
|
| 575 |
footer { display: none !important; }
|
|
|
|
| 757 |
outline-offset: 2px !important;
|
| 758 |
}
|
| 759 |
|
| 760 |
+
/* Result cards — the shortlist, posted into the conversation as its own
|
| 761 |
+
message so anything asked afterwards appears below it. Each card is a
|
| 762 |
+
native <details>, so opening one is a client-side toggle with no round
|
| 763 |
+
trip, the way the Streamlit expanders behave. */
|
| 764 |
+
.kalim-cards { direction: rtl; text-align: right; }
|
| 765 |
.kalim-major-card {
|
| 766 |
+
border: 1px solid var(--line);
|
| 767 |
+
border-radius: var(--radius);
|
| 768 |
+
background: var(--bg);
|
| 769 |
+
box-shadow: var(--kalim-shadow);
|
| 770 |
+
margin: 0 0 0.5rem 0;
|
| 771 |
overflow: hidden;
|
| 772 |
+
transition: border-color .18s ease, box-shadow .18s ease;
|
| 773 |
}
|
| 774 |
+
.kalim-major-card:last-child { margin-bottom: 0; }
|
| 775 |
+
.kalim-major-card > summary {
|
| 776 |
+
direction: rtl;
|
| 777 |
+
text-align: right;
|
| 778 |
+
font-weight: 600;
|
| 779 |
+
color: var(--ink-700);
|
| 780 |
+
padding: 0.7rem 0.9rem;
|
| 781 |
+
cursor: pointer;
|
| 782 |
+
list-style: none;
|
| 783 |
+
display: flex;
|
| 784 |
+
align-items: center;
|
| 785 |
+
gap: 0.6rem;
|
| 786 |
+
}
|
| 787 |
+
.kalim-major-card > summary::-webkit-details-marker { display: none; }
|
| 788 |
+
/* A chevron of our own: the platform marker points sideways when closed,
|
| 789 |
+
which reads as "next" rather than "expand". */
|
| 790 |
+
.kalim-major-card > summary::after {
|
| 791 |
+
content: "";
|
| 792 |
+
margin-inline-start: auto;
|
| 793 |
+
width: 0.5rem; height: 0.5rem;
|
| 794 |
+
border-right: 2px solid var(--text-faint);
|
| 795 |
+
border-bottom: 2px solid var(--text-faint);
|
| 796 |
+
transform: rotate(45deg) translateY(-2px);
|
| 797 |
+
transition: transform .18s ease;
|
| 798 |
+
flex-shrink: 0;
|
| 799 |
}
|
| 800 |
+
.kalim-major-card[open] > summary::after { transform: rotate(-135deg) translateY(-2px); }
|
| 801 |
.kalim-major-card:hover {
|
| 802 |
+
border-color: var(--line-strong);
|
| 803 |
+
box-shadow: 0 2px 6px rgba(16,36,63,.06), 0 8px 22px rgba(16,36,63,.08);
|
| 804 |
+
}
|
| 805 |
+
.kalim-major-card > summary:focus-visible {
|
| 806 |
+
outline: 2px solid var(--cedar);
|
| 807 |
+
outline-offset: -2px;
|
| 808 |
}
|
| 809 |
.kalim-major-details {
|
| 810 |
+
direction: rtl;
|
| 811 |
+
text-align: right;
|
| 812 |
+
padding: 0 0.9rem 0.75rem;
|
| 813 |
+
border-top: 1px solid var(--line);
|
| 814 |
+
padding-top: 0.6rem;
|
| 815 |
}
|
| 816 |
.kalim-major-details p { margin: 0.35rem 0 !important; }
|
| 817 |
+
.kalim-major-details strong { color: var(--ink-700); }
|
| 818 |
+
|
| 819 |
+
/* The shortlist sits inside Kalim's turn rather than replacing it: Gradio
|
| 820 |
+
groups consecutive assistant messages into a single bubble, so stripping
|
| 821 |
+
the bubble for a card message stripped it from the narration beside it too.
|
| 822 |
+
The cards take the surface tint instead, which separates them from the
|
| 823 |
+
white bubble they sit in and ties the list to the message that introduced
|
| 824 |
+
it. */
|
| 825 |
+
#kalim-chat .message .kalim-major-card { background: var(--surface); }
|
| 826 |
+
#kalim-chat .message .kalim-major-card[open],
|
| 827 |
+
#kalim-chat .message .kalim-major-card:hover { background: var(--bg); }
|
| 828 |
+
#kalim-chat .kalim-cards { margin: 0.5rem 0 0.15rem; }
|
| 829 |
+
/* A turn carrying the shortlist needs the column, not a chat bubble's width. */
|
| 830 |
+
#kalim-chat .message-row:has(.kalim-cards),
|
| 831 |
+
#kalim-chat .message.bot:has(.kalim-cards) {
|
| 832 |
+
max-width: 100% !important;
|
| 833 |
+
width: 100% !important;
|
| 834 |
+
}
|
| 835 |
|
| 836 |
/* ===== Chat ===== */
|
| 837 |
/* Styled as a modern messenger: centered column, avatars, distinct bubbles,
|
|
|
|
| 840 |
further away with every answer, so the shortlist appeared to jump after each
|
| 841 |
new reply instead of staying put. */
|
| 842 |
#kalim-main { max-width: 880px; margin: 0 auto; width: 100%; }
|
| 843 |
+
/* Before a search there is one short welcome card and a button, which left
|
| 844 |
+
roughly 370px of empty page under them on a 900px window — the app read as
|
| 845 |
+
unfinished rather than calm. Until results exist the column fills the free
|
| 846 |
+
height and centres what is in it; once the shortlist appears the class goes
|
| 847 |
+
away and normal top-down flow returns. The class is set by the layout
|
| 848 |
+
observer alongside the measurements it already takes. */
|
| 849 |
+
body:not(.kalim-has-results) .gradio-container { min-height: 100vh; }
|
| 850 |
+
/* flex-grow only reaches the column if every wrapper between it and the
|
| 851 |
+
container grows too — Gradio nests a couple of unnamed divs in there. */
|
| 852 |
+
body:not(.kalim-has-results) .gradio-container > div,
|
| 853 |
+
body:not(.kalim-has-results) div:has(> #kalim-main) {
|
| 854 |
+
flex: 1 1 auto;
|
| 855 |
+
}
|
| 856 |
+
body:not(.kalim-has-results) #kalim-main {
|
| 857 |
+
flex: 1 1 auto;
|
| 858 |
+
justify-content: center;
|
| 859 |
+
}
|
| 860 |
#kalim-chat {
|
| 861 |
border: none !important;
|
| 862 |
box-shadow: none !important;
|
|
|
|
| 873 |
#kalim-chat .bubble-wrap {
|
| 874 |
background: transparent !important;
|
| 875 |
height: auto !important;
|
| 876 |
+
/* Was a flat 58vh (46vh on phones), which left ~370px of dead space under
|
| 877 |
+
the welcome screen and squeezed the conversation on short windows. The
|
| 878 |
+
observer measures what is actually free between the chat's top and the
|
| 879 |
+
two docked layers. */
|
| 880 |
+
max-height: var(--kalim-chat-max, 58vh) !important;
|
| 881 |
overflow-y: auto !important;
|
| 882 |
+
/* Deliberately NOT `overscroll-behavior: contain`. Containing it means a
|
| 883 |
+
wheel over the conversation never reaches the page, and the conversation
|
| 884 |
+
covers most of the window — so on a short window the wizard step below
|
| 885 |
+
it was unreachable by scrolling at all. Chaining at the boundary is the
|
| 886 |
+
behaviour people expect from a pane inside a page. */
|
| 887 |
+
overscroll-behavior-y: auto;
|
| 888 |
+
scrollbar-gutter: stable;
|
| 889 |
+
}
|
| 890 |
+
/* Scrollback affordance: without it the pane looks like a conversation that
|
| 891 |
+
begins mid-sentence. The class is toggled by the head script on scroll. */
|
| 892 |
+
#kalim-chat.kalim-scrolled .bubble-wrap {
|
| 893 |
+
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 26px);
|
| 894 |
+
mask-image: linear-gradient(to bottom, transparent 0, #000 26px);
|
| 895 |
}
|
| 896 |
#kalim-chat .message-wrap { gap: 1.05rem !important; }
|
| 897 |
/* No chat chrome: the clear-history icon is the last one buttons=[] leaves. */
|
|
|
|
| 995 |
border: none !important;
|
| 996 |
box-shadow: none !important;
|
| 997 |
}
|
| 998 |
+
/* Docked to the viewport, over the main column. It used to be `position:
|
| 999 |
+
sticky; bottom: 3rem`, which is a no-op here: the textbox is the LAST child
|
| 1000 |
+
of #kalim-main, so its flow position already sits at the bottom of its
|
| 1001 |
+
containing block and sticky has no room to lift it. Measured, it sat 748px
|
| 1002 |
+
below the fold on a 1440x900 desktop and 873px below it on a 390px phone —
|
| 1003 |
+
a student saw ten result cards and no way to ask anything. `fixed` plus the
|
| 1004 |
+
measured column geometry below keeps it on screen at every width.
|
| 1005 |
+
--kalim-dock-* and --kalim-footer-h are written by the layout observer in
|
| 1006 |
+
the head script; the fallbacks keep it usable if that script never runs. */
|
| 1007 |
#kalim-input {
|
| 1008 |
+
position: fixed;
|
| 1009 |
+
bottom: calc(var(--kalim-footer-h, 2.4rem) + 0.5rem);
|
| 1010 |
+
left: var(--kalim-dock-left, 0px);
|
| 1011 |
+
width: var(--kalim-dock-width, min(880px, calc(100% - 2rem)));
|
| 1012 |
z-index: 900;
|
| 1013 |
background: var(--bg);
|
| 1014 |
border: 1px solid var(--line-strong) !important;
|
|
|
|
| 1016 |
box-shadow: 0 6px 24px rgba(16, 36, 63, 0.10);
|
| 1017 |
padding: 0.15rem 0.4rem !important;
|
| 1018 |
}
|
| 1019 |
+
/* The cards scroll underneath the pill; fade the ground out around it so they
|
| 1020 |
+
do not appear to run into its rounded edge. */
|
| 1021 |
+
#kalim-input::before {
|
| 1022 |
+
content: "";
|
| 1023 |
+
position: absolute;
|
| 1024 |
+
left: -1rem; right: -1rem;
|
| 1025 |
+
bottom: calc(-1 * var(--kalim-footer-h, 2.4rem) - 0.5rem);
|
| 1026 |
+
top: -0.9rem;
|
| 1027 |
+
z-index: -1;
|
| 1028 |
+
pointer-events: none;
|
| 1029 |
+
background: linear-gradient(to bottom, rgba(251, 252, 254, 0) 0%,
|
| 1030 |
+
rgba(251, 252, 254, 0.92) 45%,
|
| 1031 |
+
rgba(251, 252, 254, 1) 100%);
|
| 1032 |
+
}
|
| 1033 |
#kalim-input textarea {
|
| 1034 |
direction: rtl !important; text-align: right !important;
|
| 1035 |
font-size: 16px !important; /* keeps iOS Safari from zooming on focus */
|
|
|
|
| 1058 |
#kalim-input button * { color: #fff !important; fill: #fff !important; }
|
| 1059 |
|
| 1060 |
/* ===== Footer — mirrors .kalim-footer: fixed to the bottom edge ===== */
|
| 1061 |
+
/* The strip itself is out of flow, but the wrappers Gradio puts around a
|
| 1062 |
+
gr.HTML are not: their padding and the column's gap added ~52px of empty
|
| 1063 |
+
page under the last element, which is what put a scrollbar on a welcome
|
| 1064 |
+
screen that otherwise fits the window exactly. Collapse them. */
|
| 1065 |
+
.gradio-container .block:has(#kalim-footer),
|
| 1066 |
+
.gradio-container .html-container:has(#kalim-footer),
|
| 1067 |
+
.gradio-container .prose:has(#kalim-footer) {
|
| 1068 |
+
height: 0 !important;
|
| 1069 |
+
min-height: 0 !important;
|
| 1070 |
+
padding: 0 !important;
|
| 1071 |
+
margin: 0 !important;
|
| 1072 |
+
border: none !important;
|
| 1073 |
+
overflow: visible !important;
|
| 1074 |
+
}
|
| 1075 |
+
/* Zero height is not enough: a flex item still earns the column's gap, which
|
| 1076 |
+
left 32px of page below the last element. Taking it out of flow entirely
|
| 1077 |
+
removes the gap with it, and costs nothing — the strip inside is fixed. */
|
| 1078 |
+
.gradio-container .block:has(#kalim-footer) { position: absolute !important; }
|
| 1079 |
#kalim-footer {
|
| 1080 |
position: fixed;
|
| 1081 |
bottom: 0; left: 0; right: 0;
|
|
|
|
| 1088 |
margin: 0;
|
| 1089 |
z-index: 998;
|
| 1090 |
}
|
| 1091 |
+
/* Keep the last card clear of BOTH fixed layers — the footer and the docked
|
| 1092 |
+
composer. 3rem was a guess against a one-line footer; the footer wraps to
|
| 1093 |
+
two lines at 390px (54px measured), so the strip covered the last result
|
| 1094 |
+
card. Both heights are measured at runtime instead. */
|
| 1095 |
+
.gradio-container {
|
| 1096 |
+
padding-bottom: calc(var(--kalim-footer-h, 2.4rem)
|
| 1097 |
+
+ var(--kalim-dock-h, 0px) + 1.75rem) !important;
|
| 1098 |
+
}
|
| 1099 |
|
| 1100 |
@media (max-width: 1024px) {
|
| 1101 |
#kalim-header h1 { font-size: 1.45rem; }
|
|
|
|
| 1111 |
#kalim-chat .message-row .message,
|
| 1112 |
#kalim-chat .message { max-width: 100% !important; }
|
| 1113 |
#kalim-chat .message-row { gap: 0.4rem !important; }
|
| 1114 |
+
/* Height comes from --kalim-chat-max, which is measured against this
|
| 1115 |
+
viewport too — no separate phone fraction to keep in sync. */
|
| 1116 |
|
| 1117 |
/* Hide Kalim's avatar on phones: the 35px circle plus its gutter costs
|
| 1118 |
more width than it communicates, and Kalim's turns are the long ones
|
|
|
|
| 1130 |
margin-right: var(--spacing-xl) !important;
|
| 1131 |
}
|
| 1132 |
#kalim-actions { flex-wrap: wrap; }
|
| 1133 |
+
/* 1.2rem was less than the two-line footer this width produces, so the
|
| 1134 |
+
strip sat on top of the composer. The measured offset handles it. */
|
| 1135 |
+
.gradio-container {
|
| 1136 |
+
padding: 0.5rem 0.5rem calc(var(--kalim-footer-h, 3.4rem)
|
| 1137 |
+
+ var(--kalim-dock-h, 0px) + 1.5rem) !important;
|
| 1138 |
+
}
|
| 1139 |
}
|
| 1140 |
"""
|
| 1141 |
|
|
|
|
| 1181 |
})();
|
| 1182 |
</script>
|
| 1183 |
<script>
|
| 1184 |
+
/* Layout observer — writes the geometry the CSS cannot know.
|
| 1185 |
+
*
|
| 1186 |
+
* Three things on this page have no fixed size: the footer wraps to two lines
|
| 1187 |
+
* below ~700px, the main column shifts left when the sidebar opens, and the
|
| 1188 |
+
* composer is absent until results exist. Every offset that used to be a
|
| 1189 |
+
* hardcoded rem value was wrong in at least one of those states — the footer
|
| 1190 |
+
* covered the last result card at 390px, and the composer's bottom offset was
|
| 1191 |
+
* smaller than the footer it was supposed to clear. These are measured
|
| 1192 |
+
* instead, so they are right at every width, zoom level and sidebar state.
|
| 1193 |
+
*
|
| 1194 |
+
* --kalim-footer-h height of the fixed footer strip
|
| 1195 |
+
* --kalim-dock-left left edge of the main column (the composer tracks it)
|
| 1196 |
+
* --kalim-dock-width width of the main column
|
| 1197 |
+
* --kalim-dock-h height of the docked composer, 0 while it is hidden
|
| 1198 |
+
* --kalim-chat-max how tall the conversation may grow before it scrolls
|
| 1199 |
+
*/
|
| 1200 |
(function () {
|
| 1201 |
+
const root = document.documentElement;
|
| 1202 |
+
const px = n => Math.round(n) + 'px';
|
| 1203 |
+
let queued = false;
|
| 1204 |
+
|
| 1205 |
+
const set = (name, value) => {
|
| 1206 |
+
if (root.style.getPropertyValue(name) !== value) {
|
| 1207 |
+
root.style.setProperty(name, value);
|
| 1208 |
+
}
|
| 1209 |
+
};
|
| 1210 |
+
|
| 1211 |
+
const measure = () => {
|
| 1212 |
+
queued = false;
|
| 1213 |
+
const footer = document.querySelector('#kalim-footer');
|
| 1214 |
+
const main = document.querySelector('#kalim-main');
|
| 1215 |
+
const chat = document.querySelector('#kalim-chat');
|
| 1216 |
+
const dock = document.querySelector('#kalim-input');
|
| 1217 |
+
|
| 1218 |
+
const footerH = footer ? footer.offsetHeight : 0;
|
| 1219 |
+
set('--kalim-footer-h', px(footerH));
|
| 1220 |
+
|
| 1221 |
+
if (main) {
|
| 1222 |
+
const r = main.getBoundingClientRect();
|
| 1223 |
+
set('--kalim-dock-left', px(r.left));
|
| 1224 |
+
set('--kalim-dock-width', px(r.width));
|
| 1225 |
+
}
|
| 1226 |
+
|
| 1227 |
+
// offsetParent is null while Gradio keeps the textbox display:none.
|
| 1228 |
+
const dockH = dock && dock.offsetParent !== null ? dock.offsetHeight : 0;
|
| 1229 |
+
set('--kalim-dock-h', px(dockH));
|
| 1230 |
+
|
| 1231 |
+
// The composer only exists once a search has produced results, which makes
|
| 1232 |
+
// it the signal for "past the welcome step". The shortlist cards used to
|
| 1233 |
+
// be that signal, but they are inside the conversation now.
|
| 1234 |
+
document.body.classList.toggle('kalim-has-results', dockH > 0);
|
| 1235 |
+
|
| 1236 |
+
if (chat) {
|
| 1237 |
+
// Where the conversation starts, in document coordinates — stable
|
| 1238 |
+
// regardless of how far the page happens to be scrolled.
|
| 1239 |
+
const top = chat.getBoundingClientRect().top + window.scrollY;
|
| 1240 |
+
// Free height between the top of the conversation and the docked layers.
|
| 1241 |
+
// Filling the window before results exist is done in CSS (flex-grow on
|
| 1242 |
+
// the column) rather than here — every attempt to compute that height
|
| 1243 |
+
// from measurements had to guess at Gradio's own padding and gaps.
|
| 1244 |
+
let free = window.innerHeight - top - footerH - dockH - 28;
|
| 1245 |
+
|
| 1246 |
+
// Whatever sits under the conversation inside the column — the wizard
|
| 1247 |
+
// step, the action chips — has to fit too. Without this the pane took
|
| 1248 |
+
// the whole window and pushed the step below the fold: on a 720px-tall
|
| 1249 |
+
// window the "نعم، أعرف رموزي" button started at 765px, and the pointer
|
| 1250 |
+
// was over the conversation, which does not chain its scroll, so there
|
| 1251 |
+
// was no way to reach the button with a wheel. The controls do not
|
| 1252 |
+
// change height when the pane does, so this cannot oscillate.
|
| 1253 |
+
if (main) {
|
| 1254 |
+
const belowChat = main.getBoundingClientRect().bottom
|
| 1255 |
+
- chat.getBoundingClientRect().bottom;
|
| 1256 |
+
free -= Math.max(0, belowChat);
|
| 1257 |
+
}
|
| 1258 |
+
set('--kalim-chat-max', px(Math.max(200, free)));
|
| 1259 |
+
}
|
| 1260 |
+
};
|
| 1261 |
+
|
| 1262 |
+
const schedule = () => {
|
| 1263 |
+
if (queued) return;
|
| 1264 |
+
queued = true;
|
| 1265 |
+
requestAnimationFrame(measure);
|
| 1266 |
+
};
|
| 1267 |
+
|
| 1268 |
+
const start = () => {
|
| 1269 |
+
const main = document.querySelector('#kalim-main');
|
| 1270 |
+
const footer = document.querySelector('#kalim-footer');
|
| 1271 |
+
if (!main || !footer) { setTimeout(start, 250); return; }
|
| 1272 |
+
measure();
|
| 1273 |
+
if (window.ResizeObserver) {
|
| 1274 |
+
const ro = new ResizeObserver(schedule);
|
| 1275 |
+
ro.observe(main);
|
| 1276 |
+
ro.observe(footer);
|
| 1277 |
+
ro.observe(document.documentElement);
|
| 1278 |
+
}
|
| 1279 |
+
window.addEventListener('resize', schedule);
|
| 1280 |
+
// The sidebar toggle animates the column, and the wizard shows and hides
|
| 1281 |
+
// the composer — both change the numbers above.
|
| 1282 |
+
new MutationObserver(schedule).observe(document.body,
|
| 1283 |
+
{ childList: true, subtree: true, attributes: true,
|
| 1284 |
+
attributeFilter: ['class', 'style'] });
|
| 1285 |
+
// The 0.3s margin transition on the column settles after the mutation.
|
| 1286 |
+
setTimeout(measure, 400);
|
| 1287 |
+
};
|
| 1288 |
+
|
| 1289 |
+
if (document.readyState === 'loading') {
|
| 1290 |
+
document.addEventListener('DOMContentLoaded', start);
|
| 1291 |
+
} else {
|
| 1292 |
+
start();
|
| 1293 |
+
}
|
| 1294 |
+
})();
|
| 1295 |
+
</script>
|
| 1296 |
+
<script>
|
| 1297 |
+
/* Mark the conversation as scrolled, so the CSS can fade its top edge. A pane
|
| 1298 |
+
that is scrolled but shows no cue reads as a conversation starting
|
| 1299 |
+
mid-sentence. */
|
| 1300 |
+
(function () {
|
| 1301 |
+
const attach = () => {
|
| 1302 |
+
const chat = document.querySelector('#kalim-chat');
|
| 1303 |
+
const scroller = chat && chat.querySelector('.bubble-wrap');
|
| 1304 |
+
if (!scroller) { setTimeout(attach, 300); return; }
|
| 1305 |
+
const update = () => chat.classList.toggle('kalim-scrolled', scroller.scrollTop > 8);
|
| 1306 |
+
scroller.addEventListener('scroll', update, { passive: true });
|
| 1307 |
+
update();
|
| 1308 |
+
};
|
| 1309 |
+
if (document.readyState === 'loading') {
|
| 1310 |
+
document.addEventListener('DOMContentLoaded', attach);
|
| 1311 |
+
} else {
|
| 1312 |
+
attach();
|
| 1313 |
+
}
|
| 1314 |
+
})();
|
| 1315 |
+
</script>
|
| 1316 |
+
<script>
|
| 1317 |
+
/* Follow the conversation: when a new message lands (every wizard click posts
|
| 1318 |
+
one), scroll to the newest content *inside* the chat, not the page.
|
| 1319 |
+
*
|
| 1320 |
+
* Only while the student is already at the bottom. Scrolling back up through
|
| 1321 |
+
* the shortlist used to snap straight back down: the observer fires on any
|
| 1322 |
+
* mutation in the pane, and reading the conversation produces plenty of them —
|
| 1323 |
+
* opening a card, Gradio re-rendering rows. Sticking to the bottom is right
|
| 1324 |
+
* when you are watching the conversation arrive and wrong the moment you have
|
| 1325 |
+
* gone looking for something, so it is a mode, and the student sets it by
|
| 1326 |
+
* scrolling.
|
| 1327 |
+
*/
|
| 1328 |
+
(function () {
|
| 1329 |
+
const NEAR_BOTTOM = 80; // px of slack, so "almost at the bottom" still sticks
|
| 1330 |
let pending = null;
|
| 1331 |
+
let stick = true;
|
| 1332 |
+
let selfScrolling = false;
|
| 1333 |
+
let selfScrollTimer = null;
|
| 1334 |
+
|
| 1335 |
+
const watchScrolling = (scroller) => {
|
| 1336 |
+
if (scroller.__kalimWatched) return;
|
| 1337 |
+
scroller.__kalimWatched = true;
|
| 1338 |
+
scroller.addEventListener('scroll', () => {
|
| 1339 |
+
// A smooth scroll of our own fires scroll events all the way down, and
|
| 1340 |
+
// reading those as the student's position turned stick off mid-flight —
|
| 1341 |
+
// the pane stopped short of the newest message instead of reaching it.
|
| 1342 |
+
if (selfScrolling) return;
|
| 1343 |
+
const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
| 1344 |
+
stick = distance < NEAR_BOTTOM;
|
| 1345 |
+
}, { passive: true });
|
| 1346 |
+
};
|
| 1347 |
+
|
| 1348 |
+
const scrollToNewest = (scroller) => {
|
| 1349 |
+
selfScrolling = true;
|
| 1350 |
+
clearTimeout(selfScrollTimer);
|
| 1351 |
+
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' });
|
| 1352 |
+
// Long enough for the animation to land; a scroll the student makes during
|
| 1353 |
+
// it is missed, which is a far smaller cost than mistaking our own.
|
| 1354 |
+
selfScrollTimer = setTimeout(() => { selfScrolling = false; }, 900);
|
| 1355 |
+
};
|
| 1356 |
+
|
| 1357 |
const schedule = () => {
|
| 1358 |
clearTimeout(pending);
|
| 1359 |
pending = setTimeout(() => {
|
| 1360 |
const chat = document.querySelector('#kalim-chat');
|
| 1361 |
const scroller = chat && chat.querySelector('.bubble-wrap');
|
| 1362 |
+
if (!scroller) return;
|
| 1363 |
+
watchScrolling(scroller);
|
| 1364 |
+
if (!stick) return;
|
| 1365 |
+
scrollToNewest(scroller);
|
| 1366 |
}, 160);
|
| 1367 |
};
|
| 1368 |
+
|
| 1369 |
+
// Where the student has scrolled to wins, with exactly one exception: the
|
| 1370 |
+
// question they just typed. Sending it is the one action that is explicitly
|
| 1371 |
+
// "and now show me the answer", and the composer they typed it into is
|
| 1372 |
+
// docked at the bottom of the screen anyway.
|
| 1373 |
+
//
|
| 1374 |
+
// Every other button used to re-stick too — the action chips, the wizard
|
| 1375 |
+
// steps — which meant scrolling to the top and then pressing anything threw
|
| 1376 |
+
// the student straight back to the bottom.
|
| 1377 |
+
const sendFromComposer = (event) =>
|
| 1378 |
+
event.target.closest && event.target.closest('#kalim-input');
|
| 1379 |
+
document.addEventListener('click', (event) => {
|
| 1380 |
+
if (sendFromComposer(event)) stick = true;
|
| 1381 |
+
}, true);
|
| 1382 |
+
document.addEventListener('keydown', (event) => {
|
| 1383 |
+
if (event.key === 'Enter' && sendFromComposer(event)) stick = true;
|
| 1384 |
+
}, true);
|
| 1385 |
+
|
| 1386 |
const attach = () => {
|
| 1387 |
const chat = document.querySelector('#kalim-chat');
|
| 1388 |
if (!chat) { setTimeout(attach, 300); return; }
|
| 1389 |
+
const scroller = chat.querySelector('.bubble-wrap');
|
| 1390 |
+
if (scroller) watchScrolling(scroller);
|
| 1391 |
new MutationObserver(schedule).observe(chat, { childList: true, subtree: true });
|
| 1392 |
};
|
| 1393 |
+
|
| 1394 |
if (document.readyState === 'loading') {
|
| 1395 |
document.addEventListener('DOMContentLoaded', attach);
|
| 1396 |
} else {
|
|
|
|
| 1461 |
show_label=False, buttons=[],
|
| 1462 |
avatar_images=("assets/avatar-student.svg", "assets/avatar-kalim.svg"),
|
| 1463 |
elem_id="kalim-chat",
|
| 1464 |
+
# The shortlist is posted as chat content (see _cards_message), and
|
| 1465 |
+
# <details> is a standard tag, so allow_tags — which only preserves
|
| 1466 |
+
# *custom* tags such as <thinking> — does not cover it. HTML is
|
| 1467 |
+
# rendered instead, and the escaping moves into _bot/_user below so
|
| 1468 |
+
# that only markup this file wrote is ever live.
|
| 1469 |
+
sanitize_html=False,
|
| 1470 |
)
|
| 1471 |
|
| 1472 |
# Wizard steps — one visible at a time, in place of the chat controls.
|
|
|
|
| 1508 |
confirm_loc_btn = gr.Button("تأكيد", variant="primary")
|
| 1509 |
all_locs_btn = gr.Button("جميع المحافظات")
|
| 1510 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1511 |
with gr.Row(elem_id="kalim-actions", visible=False) as actions_row:
|
| 1512 |
more_btn = gr.Button("عرض تخصصات إضافية ⬇️", size="sm")
|
| 1513 |
compare_btn = gr.Button("📊 قارن التخصصات", size="sm")
|
|
|
|
| 1560 |
if __name__ == "__main__":
|
| 1561 |
# Gradio 6 takes presentation options at launch time. Hugging Face Spaces
|
| 1562 |
# runs this file as __main__, so this path serves the Space too.
|
| 1563 |
+
demo.launch(theme=theme, css=CSS, head=FORCE_LIGHT)
|
|
@@ -116,4 +116,4 @@ MSG_STATION_4 = (
|
|
| 116 |
"**المحطة الرابعة: الموقع الجغرافي**\n\n"
|
| 117 |
"اختر المحافظة أو المحافظات التي تريد ومن ثم اختر القضاء، "
|
| 118 |
"لكي أقترح لك فروع الجامعة اللبنانية الأقرب لك."
|
| 119 |
-
)
|
|
|
|
| 116 |
"**المحطة الرابعة: الموقع الجغرافي**\n\n"
|
| 117 |
"اختر المحافظة أو المحافظات التي تريد ومن ثم اختر القضاء، "
|
| 118 |
"لكي أقترح لك فروع الجامعة اللبنانية الأقرب لك."
|
| 119 |
+
)
|
|
@@ -135,7 +135,7 @@ CLOUD_MODELS: Dict[str, str] = {
|
|
| 135 |
}
|
| 136 |
|
| 137 |
LLM_TEMPERATURE = 0.7
|
| 138 |
-
LLM_MAX_TOKENS = 4096
|
| 139 |
LLM_TIMEOUT_SECONDS = 60
|
| 140 |
|
| 141 |
# RAG Configuration
|
|
|
|
| 135 |
}
|
| 136 |
|
| 137 |
LLM_TEMPERATURE = 0.7
|
| 138 |
+
LLM_MAX_TOKENS = 4096
|
| 139 |
LLM_TIMEOUT_SECONDS = 60
|
| 140 |
|
| 141 |
# RAG Configuration
|
|
@@ -9,5 +9,11 @@
|
|
| 9 |
# st.iframe fallback.
|
| 10 |
|
| 11 |
streamlit==1.62.0
|
| 12 |
-
groq==1.6.0
|
| 13 |
python-dotenv==1.2.3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
# st.iframe fallback.
|
| 10 |
|
| 11 |
streamlit==1.62.0
|
|
|
|
| 12 |
python-dotenv==1.2.3
|
| 13 |
+
|
| 14 |
+
# LangChain: engine.build_llm goes through llm.langchain_llm, so the Streamlit
|
| 15 |
+
# build needs it too — without it every chat answer silently drops to the
|
| 16 |
+
# deterministic fallback. groq is unpinned for the same reason as in
|
| 17 |
+
# requirements.txt: langchain-groq caps it at <1.0.0.
|
| 18 |
+
langchain-core==0.3.*
|
| 19 |
+
langchain-groq
|
|
@@ -34,4 +34,4 @@ langchain-community==0.3.*
|
|
| 34 |
# which conflicts with gradio==6.25.0's requirement of huggingface-hub>=1.16.
|
| 35 |
# Letting pip resolve freely tests whether a newer release has dropped that cap.
|
| 36 |
sentence-transformers
|
| 37 |
-
chromadb
|
|
|
|
| 34 |
# which conflicts with gradio==6.25.0's requirement of huggingface-hub>=1.16.
|
| 35 |
# Letting pip resolve freely tests whether a newer release has dropped that cap.
|
| 36 |
sentence-transformers
|
| 37 |
+
chromadb
|
|
@@ -80,12 +80,61 @@ def build_llm(get_secret: Callable[[str], str] = os.getenv):
|
|
| 80 |
provider's SDK directly — this is the thesis's LangChain design
|
| 81 |
proposition, implemented in the code path a student's question
|
| 82 |
actually travels through.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
"""
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
return build_langchain_llm(get_secret=get_secret)
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
# --- Search ------------------------------------------------------------------
|
| 90 |
|
| 91 |
def run_search(profile: StudentProfile) -> List[Dict]:
|
|
@@ -252,7 +301,7 @@ _UNWANTED_SCRIPTS = re.compile(
|
|
| 252 |
'一-鿿' # CJK unified ideographs
|
| 253 |
'가-' # hangul syllables
|
| 254 |
'豈-' # CJK compatibility ideographs
|
| 255 |
-
'-' # private use area
|
| 256 |
'�' # replacement character
|
| 257 |
']+'
|
| 258 |
)
|
|
@@ -854,4 +903,4 @@ def free_chat_reply(message: str, visible: List[Dict], all_results: List[Dict],
|
|
| 854 |
# checking it would reject its own shortlist names.
|
| 855 |
if reply is not fallback and not reply_consistent_with_context(reply, context):
|
| 856 |
return ChatTurn(fallback, visible)
|
| 857 |
-
return ChatTurn(reply, visible)
|
|
|
|
| 80 |
provider's SDK directly — this is the thesis's LangChain design
|
| 81 |
proposition, implemented in the code path a student's question
|
| 82 |
actually travels through.
|
| 83 |
+
|
| 84 |
+
LangChain is not installed everywhere this engine runs: requirements.txt
|
| 85 |
+
(the Hugging Face Space) carries it, requirements-deploy.txt (Streamlit
|
| 86 |
+
Community Cloud, Docker) does not have to. An ImportError therefore falls
|
| 87 |
+
back to the provider SDKs in llm.cloud_llm instead of taking the chat
|
| 88 |
+
down — the behaviour requirements.txt already claims in its comment
|
| 89 |
+
("cloud_llm.py is kept only as a documented fallback").
|
| 90 |
"""
|
| 91 |
+
try:
|
| 92 |
+
from llm.langchain_llm import build_langchain_llm
|
| 93 |
+
except ImportError:
|
| 94 |
+
logger.warning(
|
| 95 |
+
"LangChain is not installed; falling back to the provider SDKs in "
|
| 96 |
+
"llm.cloud_llm. Install langchain-core and the integration package "
|
| 97 |
+
"for your provider to use the LangChain path."
|
| 98 |
+
)
|
| 99 |
+
return _build_llm_via_sdks(get_secret)
|
| 100 |
|
| 101 |
return build_langchain_llm(get_secret=get_secret)
|
| 102 |
|
| 103 |
|
| 104 |
+
def _build_llm_via_sdks(get_secret: Callable[[str], str]):
|
| 105 |
+
"""Pre-LangChain provider selection, kept as the no-LangChain fallback."""
|
| 106 |
+
from llm.cloud_llm import PROVIDER_PRECEDENCE
|
| 107 |
+
|
| 108 |
+
for env_var, provider in PROVIDER_PRECEDENCE:
|
| 109 |
+
if not get_secret(env_var):
|
| 110 |
+
continue
|
| 111 |
+
try:
|
| 112 |
+
from llm.cloud_llm import CloudLLM
|
| 113 |
+
|
| 114 |
+
client = CloudLLM(provider, api_key=get_secret(env_var))
|
| 115 |
+
except Exception:
|
| 116 |
+
logger.exception("Failed to initialise cloud provider %s", provider)
|
| 117 |
+
continue
|
| 118 |
+
if client.is_available:
|
| 119 |
+
return client
|
| 120 |
+
# Key present but no usable client — say so instead of silently
|
| 121 |
+
# returning canned errors for every question.
|
| 122 |
+
logger.error(
|
| 123 |
+
"%s is configured but its client could not be created — either the "
|
| 124 |
+
"provider SDK is not installed (pip install %s) or client creation "
|
| 125 |
+
"failed; see the log above.", provider, provider,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
from llm.local_llm import get_llm as _get_local_llm
|
| 130 |
+
|
| 131 |
+
client = _get_local_llm()
|
| 132 |
+
except Exception:
|
| 133 |
+
logger.exception("Failed to initialise the local Ollama client")
|
| 134 |
+
return None
|
| 135 |
+
return client if client is not None and client.is_available else None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
# --- Search ------------------------------------------------------------------
|
| 139 |
|
| 140 |
def run_search(profile: StudentProfile) -> List[Dict]:
|
|
|
|
| 301 |
'一-鿿' # CJK unified ideographs
|
| 302 |
'가-' # hangul syllables
|
| 303 |
'豈-' # CJK compatibility ideographs
|
| 304 |
+
'-' # private use area
|
| 305 |
'�' # replacement character
|
| 306 |
']+'
|
| 307 |
)
|
|
|
|
| 903 |
# checking it would reject its own shortlist names.
|
| 904 |
if reply is not fallback and not reply_consistent_with_context(reply, context):
|
| 905 |
return ChatTurn(fallback, visible)
|
| 906 |
+
return ChatTurn(reply, visible)
|
|
@@ -587,8 +587,13 @@ def test_grounded_reply_passes_through():
|
|
| 587 |
|
| 588 |
# --- build_llm provider selection -----------------------------------------
|
| 589 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
def test_build_llm_returns_none_without_keys(monkeypatch):
|
| 591 |
-
monkeypatch.setattr("llm.
|
| 592 |
assert engine.build_llm(lambda name: "") is None
|
| 593 |
|
| 594 |
|
|
@@ -605,25 +610,36 @@ class _FakeCloud:
|
|
| 605 |
return True
|
| 606 |
|
| 607 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
def test_build_llm_prefers_groq(monkeypatch):
|
| 609 |
_FakeCloud.instances = []
|
| 610 |
-
monkeypatch.setattr("llm.
|
| 611 |
client = engine.build_llm({"GROQ_API_KEY": "x", "OPENAI_API_KEY": "y"}.get)
|
| 612 |
assert client.provider == "groq"
|
| 613 |
|
| 614 |
|
| 615 |
def test_build_llm_falls_through_to_next_provider(monkeypatch):
|
| 616 |
_FakeCloud.instances = []
|
| 617 |
-
monkeypatch.setattr("llm.
|
| 618 |
client = engine.build_llm({"OPENAI_API_KEY": "y"}.get)
|
| 619 |
assert client.provider == "openai"
|
| 620 |
|
| 621 |
|
| 622 |
def test_build_llm_ranks_together_above_openai(monkeypatch):
|
| 623 |
-
"""One precedence list now:
|
| 624 |
-
to keep a second copy that omitted Together entirely."""
|
| 625 |
_FakeCloud.instances = []
|
| 626 |
-
monkeypatch.setattr("llm.
|
| 627 |
client = engine.build_llm({"TOGETHER_API_KEY": "t", "OPENAI_API_KEY": "o"}.get)
|
| 628 |
assert client.provider == "together"
|
| 629 |
|
|
@@ -632,6 +648,6 @@ def test_build_llm_passes_the_resolved_secret_to_the_client(monkeypatch):
|
|
| 632 |
"""Keys that live only in st.secrets must reach the SDK client: the
|
| 633 |
client used to re-read os.getenv and silently found nothing."""
|
| 634 |
_FakeCloud.instances = []
|
| 635 |
-
monkeypatch.setattr("llm.
|
| 636 |
engine.build_llm({"GROQ_API_KEY": "resolved-by-the-shell"}.get)
|
| 637 |
assert _FakeCloud.instances[0].kwargs["api_key"] == "resolved-by-the-shell"
|
|
|
|
| 587 |
|
| 588 |
# --- build_llm provider selection -----------------------------------------
|
| 589 |
|
| 590 |
+
# build_llm now delegates to llm.langchain_llm.build_langchain_llm, so these
|
| 591 |
+
# patch LangChainLLM rather than cloud_llm.CloudLLM. The contract under test is
|
| 592 |
+
# unchanged — precedence order and passing the shell-resolved secret through —
|
| 593 |
+
# because langchain_llm.PROVIDER_PRECEDENCE is the same list.
|
| 594 |
+
|
| 595 |
def test_build_llm_returns_none_without_keys(monkeypatch):
|
| 596 |
+
monkeypatch.setattr("llm.langchain_llm.LangChainLLM", _unavailable_client)
|
| 597 |
assert engine.build_llm(lambda name: "") is None
|
| 598 |
|
| 599 |
|
|
|
|
| 610 |
return True
|
| 611 |
|
| 612 |
|
| 613 |
+
def _unavailable_client(provider, **kwargs):
|
| 614 |
+
"""Stand-in for a provider whose client cannot be built (no Ollama here).
|
| 615 |
+
|
| 616 |
+
Raises rather than returning an unavailable _FakeCloud: reaching into
|
| 617 |
+
_FakeCloud to flip its is_available rebinds the attribute on the class
|
| 618 |
+
every other test shares, so the ones that ran afterwards saw a client that
|
| 619 |
+
was never available.
|
| 620 |
+
"""
|
| 621 |
+
raise RuntimeError("no client for " + provider)
|
| 622 |
+
|
| 623 |
+
|
| 624 |
def test_build_llm_prefers_groq(monkeypatch):
|
| 625 |
_FakeCloud.instances = []
|
| 626 |
+
monkeypatch.setattr("llm.langchain_llm.LangChainLLM", _FakeCloud)
|
| 627 |
client = engine.build_llm({"GROQ_API_KEY": "x", "OPENAI_API_KEY": "y"}.get)
|
| 628 |
assert client.provider == "groq"
|
| 629 |
|
| 630 |
|
| 631 |
def test_build_llm_falls_through_to_next_provider(monkeypatch):
|
| 632 |
_FakeCloud.instances = []
|
| 633 |
+
monkeypatch.setattr("llm.langchain_llm.LangChainLLM", _FakeCloud)
|
| 634 |
client = engine.build_llm({"OPENAI_API_KEY": "y"}.get)
|
| 635 |
assert client.provider == "openai"
|
| 636 |
|
| 637 |
|
| 638 |
def test_build_llm_ranks_together_above_openai(monkeypatch):
|
| 639 |
+
"""One precedence list now: langchain_llm.PROVIDER_PRECEDENCE. The engine
|
| 640 |
+
used to keep a second copy that omitted Together entirely."""
|
| 641 |
_FakeCloud.instances = []
|
| 642 |
+
monkeypatch.setattr("llm.langchain_llm.LangChainLLM", _FakeCloud)
|
| 643 |
client = engine.build_llm({"TOGETHER_API_KEY": "t", "OPENAI_API_KEY": "o"}.get)
|
| 644 |
assert client.provider == "together"
|
| 645 |
|
|
|
|
| 648 |
"""Keys that live only in st.secrets must reach the SDK client: the
|
| 649 |
client used to re-read os.getenv and silently found nothing."""
|
| 650 |
_FakeCloud.instances = []
|
| 651 |
+
monkeypatch.setattr("llm.langchain_llm.LangChainLLM", _FakeCloud)
|
| 652 |
engine.build_llm({"GROQ_API_KEY": "resolved-by-the-shell"}.get)
|
| 653 |
assert _FakeCloud.instances[0].kwargs["api_key"] == "resolved-by-the-shell"
|