ya02 commited on
Commit
2c2960f
·
verified ·
1 Parent(s): 9c653b9

Fix the chat box being off-screen, the scroll traps, and a sanitiser bug eating hyphens

Browse files

Hi — 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 CHANGED
@@ -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 }`)
app_gradio.py CHANGED
@@ -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
- try:
77
- from rag.vectorstore import get_vectorstore
78
-
79
- _vs = get_vectorstore()
80
- if _vs.collection.count() == 0:
81
- print("Building semantic search index (first run)...")
82
- _vs.build_index()
83
- print(f"Index built: {_vs.collection.count()} documents")
84
- except Exception as _e:
85
- print(f"Semantic index not built (optional feature disabled): {_e}")
 
 
 
 
 
 
 
 
 
 
 
 
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
- history.append({'role': 'user', 'content': f"بحث جديد: {summary}"})
162
  reply = engine.results_intro(results, codes, interactive_cards=False)
163
- return (_bot(history, reply), state, *_vis('results'))
 
 
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 _bot(history, reply), state
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
- history.append({'role': 'user', 'content': message})
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 "", _bot(history, turn.reply), state
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") !important;
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 browsable shortlist under the conversation. Each is a
621
- collapsed accordion; opening one is a client-side toggle, so details appear
622
- instantly the way the Streamlit expanders do. */
 
 
623
  .kalim-major-card {
624
- border: 1px solid var(--line) !important;
625
- border-radius: var(--radius) !important;
626
- background: var(--bg) !important;
627
- box-shadow: var(--kalim-shadow) !important;
628
- margin: 0 0 0.5rem 0 !important;
629
  overflow: hidden;
 
630
  }
631
- .kalim-major-card > .label-wrap,
632
- .kalim-major-card button.label-wrap {
633
- direction: rtl !important;
634
- text-align: right !important;
635
- font-weight: 600 !important;
636
- color: var(--ink-700) !important;
637
- padding: 0.7rem 0.9rem !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
  }
 
639
  .kalim-major-card:hover {
640
- border-color: var(--line-strong) !important;
 
 
 
 
 
641
  }
642
  .kalim-major-details {
643
- direction: rtl !important;
644
- text-align: right !important;
645
- padding: 0 0.9rem 0.6rem !important;
 
 
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
- max-height: 58vh !important;
 
 
 
 
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: sticky;
780
- bottom: 3rem;
 
 
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 controls clear of the fixed strip. */
829
- .gradio-container { padding-bottom: 3rem !important; }
 
 
 
 
 
 
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
- /* Leave room for the cards and the input on a short screen. */
846
- #kalim-chat .bubble-wrap { max-height: 46vh !important; }
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
- #kalim-input { bottom: 1.2rem; }
865
- .gradio-container { padding: 0.5rem 0.5rem 3rem !important; }
 
 
 
 
866
  }
867
  """
868
 
@@ -908,27 +1181,216 @@ FORCE_LIGHT = """
908
  })();
909
  </script>
910
  <script>
911
- /* Follow the conversation: whenever a new message lands (every wizard click
912
- posts one), scroll to the newest content *inside* the chat. Scrolling the
913
- whole page instead would drag the result cards below the fold on every
914
- answer — the conversation moves, the shortlist stays where it is. */
 
 
 
 
 
 
 
 
 
 
 
 
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
- scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' });
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)
config/prompts.py CHANGED
@@ -116,4 +116,4 @@ MSG_STATION_4 = (
116
  "**المحطة الرابعة: الموقع الجغرافي**\n\n"
117
  "اختر المحافظة أو المحافظات التي تريد ومن ثم اختر القضاء، "
118
  "لكي أقترح لك فروع الجامعة اللبنانية الأقرب لك."
119
- )
 
116
  "**المحطة الرابعة: الموقع الجغرافي**\n\n"
117
  "اختر المحافظة أو المحافظات التي تريد ومن ثم اختر القضاء، "
118
  "لكي أقترح لك فروع الجامعة اللبنانية الأقرب لك."
119
+ )
config/settings.py CHANGED
@@ -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
requirements-deploy.txt CHANGED
@@ -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
requirements.txt CHANGED
@@ -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
src/core/engine.py CHANGED
@@ -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
- from llm.langchain_llm import build_langchain_llm
 
 
 
 
 
 
 
 
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)
tests/test_engine.py CHANGED
@@ -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.local_llm.get_llm", lambda: None)
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.cloud_llm.CloudLLM", _FakeCloud)
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.cloud_llm.CloudLLM", _FakeCloud)
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: cloud_llm.PROVIDER_PRECEDENCE. The engine used
624
- to keep a second copy that omitted Together entirely."""
625
  _FakeCloud.instances = []
626
- monkeypatch.setattr("llm.cloud_llm.CloudLLM", _FakeCloud)
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.cloud_llm.CloudLLM", _FakeCloud)
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"