From 23646e38932f420b56486c3f9b3abc6a7d544f70 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 15:12:53 +0000 Subject: [PATCH 1/3] =?UTF-8?q?perf(search):=20=D7=94=D7=90=D7=99=D7=A0?= =?UTF-8?q?=D7=93=D7=A7=D7=A1=20=D7=91=D7=96=D7=99=D7=9B=D7=A8=D7=95=D7=9F?= =?UTF-8?q?=20=D7=A0=D7=91=D7=A0=D7=94=20=D7=A8=D7=A7=20=D7=91=D7=97=D7=99?= =?UTF-8?q?=D7=A4=D7=95=D7=A9=D7=99=D7=9D=20=D7=A9=D7=A7=D7=95=D7=A8=D7=90?= =?UTF-8?q?=D7=99=D7=9D=20=D7=9E=D7=9E=D7=A0=D7=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ‏`AdvancedSearchEngine.search` בנה את `SearchIndex` לפני שבדק איזה סוג חיפוש התבקש. רק `TEXT` ו-`FUNCTION` קוראים מהאינדקס; `CONTENT`, `REGEX` ו-`FUZZY` סורקים את ה-DB בעצמם ואינם נוגעים בו. מכיוון ש-`CONTENT` הוא ברירת המחדל של החיפוש ב-WebApp, המסלול הרגיל שילם על סריקה מלאה של כל קבצי המשתמש (כולל `code`, במנות של `SEARCH_PAGE_SIZE`, עם קיבוץ גרסאות מלא בכל עמוד) שאיש לא קרא את תוצאתה — ומיד אחריה סריקה מלאה שנייה לחיפוש עצמו. מה השתנה: - ‏`get_index` נקרא רק בענפי `TEXT` ו-`FUNCTION`, דרך `_get_index_for_search` ששומרת על מדידת `search_index_get` הקיימת. - הפרמטר `index` הוסר מ-`_fuzzy_search`; הוא התקבל ומעולם לא נקרא, וזה מה שהסתיר את העובדה שהמסלול אינו נשען על האינדקס. - נוסף `SEARCH_MEMORY_INDEX_ENABLED` (ברירת מחדל `true`) לכיבוי מלא של האינדקס בזיכרון. כבוי ⇒ `TEXT` ו-`FUNCTION` מחזירים רשימה ריקה, ו- ‏`_safe_search` ב-WebApp נופל לחיפוש `$text` במונגו. מה שנעלם: התאמה חלקית (prefix/substring) ו-`function_index`. התיעוד ב-`docs/workflows/search-flow.rst` עודכן בהתאם, כולל היחס בין האינדקס בזיכרון לאינדקס `search_text_idx` של מונגו. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RgnHZBJaH4VhdFFgwYZKLr --- config.py | 9 ++ docs/configuration.rst | 1 + docs/environment-variables.rst | 6 + docs/workflows/search-flow.rst | 55 +++++-- search_engine.py | 51 ++++-- services/config_inspector_service.py | 10 ++ tests/test_config.py | 14 ++ ...earch_engine_index_built_only_when_used.py | 151 ++++++++++++++++++ 8 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 tests/test_search_engine_index_built_only_when_used.py diff --git a/config.py b/config.py index b08e45fd6..bbaffbbde 100644 --- a/config.py +++ b/config.py @@ -403,6 +403,15 @@ class BotConfig(BaseSettings): description="Enable Community Library (public catalog, bot submit/approve, API/UI)" ) + # Search engine + SEARCH_MEMORY_INDEX_ENABLED: bool = Field( + default=True, + description=( + "Enable the in-process SearchIndex (word/function/language/tag maps). " + "When disabled, TEXT and FUNCTION searches fall back to MongoDB $text." + ), + ) + # Pagination defaults SEARCH_PAGE_SIZE: int = Field( default=200, diff --git a/docs/configuration.rst b/docs/configuration.rst index a74b6f882..e8c5ce808 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -165,6 +165,7 @@ Config via Pydantic Settings - ``AIOHTTP_POOL_LIMIT`` – גודל בריכת חיבורים ברירת מחדל ל‑aiohttp - ``AIOHTTP_TIMEOUT_TOTAL`` – Timeout כולל לשיחות aiohttp (שניות) - ``REDIS_MAX_CONNECTIONS`` / ``REDIS_CONNECT_TIMEOUT`` / ``REDIS_SOCKET_TIMEOUT`` – כוונון חיבורי Redis +- ``SEARCH_MEMORY_INDEX_ENABLED`` – הפעלת אינדקס החיפוש בזיכרון התהליך (ראו :doc:`/workflows/search-flow`) - ``SEARCH_PAGE_SIZE`` – גודל דף ברירת מחדל לעימוד חיפוש בצד ה‑DB - ``UI_PAGE_SIZE`` – גודל דף ברירת מחדל לרשימות ב‑UI diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index e768904e0..97c9372a6 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -1144,6 +1144,12 @@ - ``500`` - ``300`` - WebApp + * - ``SEARCH_MEMORY_INDEX_ENABLED`` + - הפעלת אינדקס החיפוש בזיכרון התהליך (``SearchIndex``). ``false`` מכבה את בנייתו, וחיפושי ``text`` ו-``function`` נופלים ל-``$text`` של מונגו במקום להישען עליו + - לא + - ``true`` + - ``false`` + - WebApp * - ``SEARCH_PAGE_SIZE`` - גודל דף חיפוש בצד ה‑DB - לא diff --git a/docs/workflows/search-flow.rst b/docs/workflows/search-flow.rst index 81323449f..c135b5c01 100644 --- a/docs/workflows/search-flow.rst +++ b/docs/workflows/search-flow.rst @@ -84,23 +84,25 @@ H->>H: פרסור שאילתה ופילטרים H->>SE: search(query, search_type, filters) - SE->>IDX: בדיקת עדכניות אינדקס - alt אינדקס לא מעודכן - IDX->>DB: בניית אינדקס מחדש - DB-->>IDX: כל הקבצים - IDX->>IDX: בניית word_index, function_index, etc. - end - SE->>SE: ביצוע חיפוש לפי סוג - alt Text/Content Search + alt Text Search + SE->>IDX: בדיקת עדכניות אינדקס + alt אינדקס לא מעודכן + IDX->>DB: בניית אינדקס מחדש + DB-->>IDX: כל הקבצים + IDX->>IDX: בניית word_index, function_index, etc. + end SE->>IDX: חיפוש ב-word_index + else Function Search + SE->>IDX: בדיקת עדכניות אינדקס (ובנייה אם צריך) + SE->>IDX: חיפוש ב-function_index else Regex Search SE->>SE: בדיקת ReDoS protection SE->>SE: ביצוע regex search else Fuzzy Search - SE->>SE: rapidfuzz.fuzz.ratio() - else Function Search - SE->>IDX: חיפוש ב-function_index + SE->>SE: rapidfuzz.fuzz.partial_ratio() + else Content Search + SE->>DB: סריקת תוכן בעימוד end SE->>DB: שליפת קבצים תואמים @@ -113,7 +115,33 @@ מבנה SearchIndex ----------------- -האינדקס נבנה מחדש כל 24 שעות או על פי דרישה: +‏``SearchIndex`` הוא אינדקס הפוך **בזיכרון התהליך**: מילון שממפה מילה, שם +פונקציה, שפה או תגית אל קבוצת הקבצים שמכילים אותם. הוא נבנה מחדש כאשר הוא +מתיישן — ראו ``should_rebuild`` — ומת עם התהליך. + +.. important:: + + האינדקס נבנה **רק** עבור ``SearchType.TEXT`` ו-``SearchType.FUNCTION``, שהם + הסוגים היחידים שקוראים ממנו. ‏``CONTENT``, ``REGEX`` ו-``FUZZY`` סורקים את + ה-DB בעצמם. עד לתיקון הזה הבנייה קדמה ל-dispatch ורצה בכל חיפוש, כולל + ``CONTENT`` — ברירת המחדל של ה-WebApp — כלומר סריקה מלאה של כל קבצי המשתמש + (עם ``code``) שאיש לא קרא את תוצאתה, ומיד אחריה עוד סריקה מלאה לחיפוש עצמו. + +.. note:: + + **מה היחס לאינדקס ה-TEXT של מונגו?** ‏``search_text_idx`` על ``code_snippets`` + (ראו :doc:`/database/indexing`) עושה את אותה עבודה עבור ``word_index``, ואף + בצורה טובה יותר: הוא על הדיסק, משותף לכל התהליכים, ומתעדכן בכל כתיבה במקום + להשתהות עד ה-rebuild הבא. מה שיש כאן ואין שם: ``function_index``, שנבנה + מהרצת ``code_processor.extract_functions`` ומונגו אינו יודע לייצר, וההתאמה + החלקית (prefix/substring) של ``_text_search``, ש-``$text`` אינו תומך בה. + + ``SEARCH_MEMORY_INDEX_ENABLED=false`` מכבה את האינדקס בזיכרון לגמרי. במצב + כזה ``TEXT`` ו-``FUNCTION`` מחזירים רשימה ריקה, וב-WebApp ``_safe_search`` + נופל מהם לחיפוש ``$text`` ישירות במונגו — כלומר שני הסוגים ממשיכים להחזיר + תוצאות, בלי שתי היכולות שלמעלה. + +מבנה האינדקס: .. code-block:: python @@ -272,8 +300,9 @@ Edge Cases - לוג warning **אינדקס לא קיים:** -- נבנה אוטומטית לפני החיפוש +- נבנה אוטומטית בענפים שצורכים אותו (``TEXT``/``FUNCTION``) לפני החיפוש - יכול לקחת זמן לקבצים רבים +- ``SEARCH_MEMORY_INDEX_ENABLED=false`` ⇒ אינו נבנה כלל, והחיפוש נופל ל-``$text`` **Regex לא תקין:** - נדחה עם הודעת שגיאה diff --git a/search_engine.py b/search_engine.py index e8992cf09..2c875b768 100644 --- a/search_engine.py +++ b/search_engine.py @@ -647,6 +647,16 @@ def _run(): return await asyncio.to_thread(_run) +def _memory_index_enabled() -> bool: + """האם האינדקס בזיכרון (``SearchIndex``) פעיל. + + ברירת המחדל היא ``True``. הכיבוי נועד למי שמעדיף לוותר על החיפושים שנשענים + על האינדקס במקום לשלם על בנייתו — סריקה מלאה של כל קבצי המשתמש, כולל + ``code``, אחת ל-30 דקות לכל תהליך. + """ + return bool(getattr(config, "SEARCH_MEMORY_INDEX_ENABLED", True)) + + class SearchIndex: """אינדקס חיפוש לביצועים טובים יותר""" @@ -800,8 +810,19 @@ def __init__(self): } def get_index(self, user_id: int) -> SearchIndex: - """קבלת אינדקס למשתמש""" - + """קבלת אינדקס למשתמש (בונה אותו אם אינו קיים או התיישן). + + כאשר ``SEARCH_MEMORY_INDEX_ENABLED`` כבוי מוחזר אינדקס ריק **בלי** בנייה + ובלי לשמור אותו ב-``self.indexes``: החיפושים שנשענים עליו (TEXT ו-FUNCTION) + יחזירו רשימה ריקה, וה-caller ב-WebApp (``_safe_search``) נופל משם לחיפוש + ``$text`` ישירות ב-MongoDB. מה שנעלם עם הכיבוי הוא ``function_index``, + שמונגו אינו יודע לייצר, וההתאמה החלקית (prefix/substring) של ``_text_search``, + ש-``$text`` אינו תומך בה. + """ + + if not _memory_index_enabled(): + return SearchIndex() + if user_id not in self.indexes: self.indexes[user_id] = SearchIndex() @@ -810,6 +831,12 @@ def get_index(self, user_id: int) -> SearchIndex: index.rebuild_index(user_id) return index + + def _get_index_for_search(self, user_id: int) -> SearchIndex: + """עטיפה מדודה סביב ``get_index`` עבור ענפי החיפוש שצורכים את האינדקס.""" + + with track_performance("search_index_get", labels={"repo": ""}): + return self.get_index(user_id) @traced("search_engine.search") def search(self, user_id: int, query: str, search_type: SearchType = SearchType.TEXT, @@ -850,23 +877,27 @@ def search(self, user_id: int, query: str, search_type: SearchType = SearchType. pass return [] - # קבלת האינדקס - with track_performance("search_index_get", labels={"repo": ""}): - index = self.get_index(user_id) - - # ביצוע החיפוש לפי סוג + # ביצוע החיפוש לפי סוג. + # + # האינדקס בזיכרון נבנה רק בענפים שקוראים ממנו: TEXT ו-FUNCTION. + # CONTENT, REGEX ו-FUZZY סורקים את ה-DB בעצמם ואינם נוגעים בו, ולכן + # בנייה עבורם הייתה סריקה מלאה נוספת של כל קבצי המשתמש (כולל code) + # שאיש אינו קורא — ו-CONTENT הוא ברירת המחדל של החיפוש ב-WebApp. with track_performance("search_execute", labels={"repo": ""}): if search_type == SearchType.TEXT: + index = self._get_index_for_search(user_id) candidates = self._text_search(query, index, user_id) elif search_type == SearchType.REGEX: candidates = self._regex_search(query, user_id) elif search_type == SearchType.FUZZY: - candidates = self._fuzzy_search(query, index, user_id) + candidates = self._fuzzy_search(query, user_id) elif search_type == SearchType.FUNCTION: + index = self._get_index_for_search(user_id) candidates = self._function_search(query, index, user_id) elif search_type == SearchType.CONTENT: candidates = self._content_search(query, user_id) else: + index = self._get_index_for_search(user_id) candidates = self._text_search(query, index, user_id) # החלת מסננים @@ -1009,8 +1040,8 @@ def _regex_search(self, pattern: str, user_id: int) -> List[SearchResult]: return results - def _fuzzy_search(self, query: str, index: SearchIndex, user_id: int) -> List[SearchResult]: - """חיפוש מטושטש (fuzzy)""" + def _fuzzy_search(self, query: str, user_id: int) -> List[SearchResult]: + """חיפוש מטושטש (fuzzy) — סורק את ה-DB ואינו נשען על האינדקס בזיכרון.""" PAGE_SIZE = int(getattr(config, "SEARCH_PAGE_SIZE", 200)) offset = 0 diff --git a/services/config_inspector_service.py b/services/config_inspector_service.py index 25cc259dd..b2cfc163d 100644 --- a/services/config_inspector_service.py +++ b/services/config_inspector_service.py @@ -2662,6 +2662,16 @@ class ConfigService: description="ימים לשמירת התחברות קבועה", category="limits", ), + "SEARCH_MEMORY_INDEX_ENABLED": ConfigDefinition( + key="SEARCH_MEMORY_INDEX_ENABLED", + services=("webapp",), + default="true", + description=( + "אינדקס החיפוש בזיכרון (SearchIndex). כבוי = חיפושי 'טקסט' ו'פונקציות' " + "נופלים ל-$text של מונגו, ואין בניית אינדקס בבקשה" + ), + category="features", + ), "SEARCH_PAGE_SIZE": ConfigDefinition( key="SEARCH_PAGE_SIZE", services=("webapp", "bot", "webserver"), diff --git a/tests/test_config.py b/tests/test_config.py index adc492e3c..aac9b044a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -68,3 +68,17 @@ def test_mongodb_url_validation(monkeypatch): with pytest.raises(ValueError): _import_fresh_config() + + +def test_search_memory_index_switch_defaults_on_and_reads_the_env(monkeypatch): + """``SEARCH_MEMORY_INDEX_ENABLED`` — ברירת מחדל דלוקה, וניתן לכבות מה-ENV.""" + monkeypatch.setenv("BOT_TOKEN", "123:TEST") + monkeypatch.setenv("MONGODB_URL", "mongodb://localhost:27017/test_db") + + cfg = _import_fresh_config() + + monkeypatch.delenv("SEARCH_MEMORY_INDEX_ENABLED", raising=False) + assert cfg.load_config().SEARCH_MEMORY_INDEX_ENABLED is True + + monkeypatch.setenv("SEARCH_MEMORY_INDEX_ENABLED", "false") + assert cfg.load_config().SEARCH_MEMORY_INDEX_ENABLED is False diff --git a/tests/test_search_engine_index_built_only_when_used.py b/tests/test_search_engine_index_built_only_when_used.py new file mode 100644 index 000000000..1c5abd8d8 --- /dev/null +++ b/tests/test_search_engine_index_built_only_when_used.py @@ -0,0 +1,151 @@ +"""האינדקס בזיכרון נבנה רק עבור סוגי החיפוש שקוראים ממנו. + +``AdvancedSearchEngine.search`` נהג לבנות את ``SearchIndex`` לפני שבדק איזה סוג +חיפוש התבקש. ‏``CONTENT``, ``REGEX`` ו-``FUZZY`` סורקים את ה-DB בעצמם ואינם +נוגעים באינדקס — ולכן הבנייה עבורם הייתה סריקה מלאה נוספת של כל קבצי המשתמש, +כולל ``code``, שאיש אינו קורא. ‏``CONTENT`` הוא ברירת המחדל של החיפוש ב-WebApp, +כך שזה היה המסלול הרגיל. + +הטסטים כאן נועלים את שני הכיוונים: שהאינדקס **לא** נבנה במסלולים שאינם צורכים +אותו, ושהוא **כן** ממשיך להיבנות ב-``TEXT`` וב-``FUNCTION``, שבלעדיו אין להם +מה להחזיר. +""" + +import pytest + +import search_engine as se + + +@pytest.fixture +def rebuild_spy(monkeypatch): + """מחליף את ``rebuild_index`` במרגל שסופר קריאות ואינו פונה ל-DB.""" + calls: list[int] = [] + + def _spy(self, user_id: int) -> None: + calls.append(int(user_id)) + # מדמה בנייה שהסתיימה, כדי ש-``should_rebuild`` לא יבקש עוד אחת + self.last_update = se.datetime.now(se.timezone.utc) + + monkeypatch.setattr(se.SearchIndex, "rebuild_index", _spy, raising=True) + return calls + + +class _StubDB: + """דמה של שכבת ה-DB עם קובץ יחיד שמכיל את המילה ``needle``.""" + + def __init__(self) -> None: + self.doc = { + "file_name": "demo.py", + "code": "def helper():\n return 'needle'\n", + "programming_language": "python", + "tags": ["demo"], + "created_at": se.datetime.now(se.timezone.utc), + "updated_at": se.datetime.now(se.timezone.utc), + "version": 1, + } + + def get_user_files(self, user_id, limit=200, *, skip=0, projection=None): + # עמוד ראשון בלבד; אחריו רשימה ריקה כדי לעצור את לולאת העימוד + return [dict(self.doc)] if skip == 0 else [] + + def get_latest_version(self, user_id, file_name): + return dict(self.doc) + + +@pytest.fixture +def stub_db(monkeypatch): + db = _StubDB() + monkeypatch.setattr(se, "db", db, raising=False) + return db + + +@pytest.mark.parametrize( + "search_type", + [se.SearchType.CONTENT, se.SearchType.REGEX, se.SearchType.FUZZY], +) +def test_a_search_that_never_reads_the_index_does_not_build_it( + search_type, rebuild_spy, stub_db +): + engine = se.AdvancedSearchEngine() + + engine.search(user_id=1, query="needle", search_type=search_type) + + assert rebuild_spy == [], ( + f"{search_type} אינו קורא מהאינדקס, ולכן אסור שיפעיל בנייה שלו" + ) + + +@pytest.mark.parametrize( + "search_type", + [se.SearchType.TEXT, se.SearchType.FUNCTION], +) +def test_a_search_that_reads_the_index_still_builds_it( + search_type, rebuild_spy, stub_db +): + engine = se.AdvancedSearchEngine() + + engine.search(user_id=1, query="needle", search_type=search_type) + + assert rebuild_spy == [1], ( + f"{search_type} נשען על האינדקס — בלי בנייה אין לו מה להחזיר" + ) + + +def test_an_unknown_search_type_falls_back_to_text_and_builds_the_index( + rebuild_spy, stub_db +): + """``SearchType.SEMANTIC`` נופל ל-``_text_search`` דרך ה-``else``.""" + engine = se.AdvancedSearchEngine() + + engine.search(user_id=1, query="needle", search_type=se.SearchType.SEMANTIC) + + assert rebuild_spy == [1] + + +@pytest.mark.parametrize( + "search_type", + [se.SearchType.TEXT, se.SearchType.FUNCTION], +) +def test_the_env_switch_turns_the_memory_index_off( + search_type, rebuild_spy, stub_db, monkeypatch +): + """``SEARCH_MEMORY_INDEX_ENABLED=false`` מכבה גם את הענפים שכן צורכים אותו.""" + monkeypatch.setattr(se.config, "SEARCH_MEMORY_INDEX_ENABLED", False, raising=False) + engine = se.AdvancedSearchEngine() + + results = engine.search(user_id=1, query="needle", search_type=search_type) + + assert rebuild_spy == [] + # אינדקס ריק ⇒ אין מועמדים; ב-WebApp ``_safe_search`` נופל מכאן ל-$text + assert results == [] + # אינדקס שלא נבנה גם לא נשמר, כדי שלא ידלוף כאינדקס "טרי" להשלמות + assert engine.indexes == {} + + +def test_the_switch_is_on_when_the_config_does_not_carry_it(): + """ברירת המחדל שומרת על ההתנהגות הקיימת — הכיבוי הוא בחירה מפורשת. + + ‏``tests/config.py`` מצל על מודול הקונפיג האמיתי ואינו מכיל את השדה, ולכן + מה שנבדק כאן הוא בדיוק נתיב ה-fallback: קונפיג בלי המפתח ⇒ האינדקס פעיל. + ברירת המחדל של השדה עצמו ב-``BotConfig`` נבדקת ב-``tests/test_config.py``. + """ + assert not hasattr(se.config, "SEARCH_MEMORY_INDEX_ENABLED") + assert se._memory_index_enabled() is True + + +def test_the_log_line_the_user_sees_is_gone_for_a_content_search(stub_db, caplog): + """אימות מול הפלט שהמשתמש באמת רואה, עם ``rebuild_index`` האמיתי. + + השורה ``בונה אינדקס חיפוש עבור משתמש `` היא הסימפטום שדווח מהלוג של + הפרודקשן. הטסטים שמעליה מרגלים אחרי הקריאה; זה בודק את התוצאה הנצפית. + """ + engine = se.AdvancedSearchEngine() + + with caplog.at_level("INFO", logger="search_engine"): + engine.search(user_id=6865105071, query="needle", search_type=se.SearchType.CONTENT) + assert "בונה אינדקס חיפוש" not in caplog.text + + caplog.clear() + with caplog.at_level("INFO", logger="search_engine"): + engine.search(user_id=6865105071, query="needle", search_type=se.SearchType.TEXT) + assert "בונה אינדקס חיפוש" in caplog.text From 77e3391e126ff60cde537af26fcf927628c17662 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 18:54:01 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(search):=20=D7=9E=D7=AA=D7=92=20=D7=9C?= =?UTF-8?q?=D7=97=D7=99=D7=9E=D7=95=D7=9D=20=D7=94=D7=90=D7=99=D7=A0=D7=93?= =?UTF-8?q?=D7=A7=D7=A1,=20=D7=95=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=AA?= =?UTF-8?q?=D7=A6=D7=95=D7=92=D7=AA=20=D7=94=D7=94=D7=A9=D7=9C=D7=9E=D7=94?= =?UTF-8?q?=20=D7=94=D7=90=D7=95=D7=98=D7=95=D7=9E=D7=98=D7=99=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit המשך ל-#3351, אחרי שהתברר שהסרת בניית האינדקס מחיפוש `content` גררה רגרסיה בהשלמה האוטומטית — ושהתצוגה שלה שבורה ממילא. מתג `SEARCH_MEMORY_INDEX_EAGER_BUILD` (ברירת מחדל `false`) ---------------------------------------------------------- `suggest_completions` קורא את האינדקס דרך `_get_ready_index`, שבמכוון אינו בונה אותו — ולכן ההשלמה נהנתה מאינדקס שחיפוש `content` בנה כתופעת לוואי. הרצת בקרה מול הקוד שלפני #3351, אותו קלט: לפני הצעות `['needle','needlehelper']`, אחרי `[]`. המתג מאפשר להחזיר את החימום מ-ENV, בלי דיפלוי קוד, כדי לשקול את העלות מול התועלת על נתונים אמיתיים. הוא כפוף ל-`SEARCH_MEMORY_INDEX_ENABLED`. תיקון זליגה באינדקס ב-`_text_search` ------------------------------------- `word_index.get(word, set())` מחזיר את הקבוצה השמורה עצמה, וה-`update` אחריו הזליג את התאמות ה-prefix לתוכה לצמיתות. מאותו רגע הן נספרו כהתאמות מדויקות וקיבלו 2.0 במקום 1.0, כלומר הדירוג נשחק בכל חיפוש נוסף על אותו אינדקס. תצוגת ההשלמה האוטומטית ----------------------- שלושה שורשים, כולם נמדדו ב-Chromium על ה-CSS של הריפו: - הוובאפ אינו טוען את ה-CSS של Bootstrap, ולכן `list-group-item` על כל הצעה לא עיצב דבר: ההצעות היו `` עם `display:inline` וללא ריפוד, וחמש מהן נדחסו לשורה אחת בגובה 20 פיקסל. הוחלף במחלקה של הפרויקט, `search-suggestion`. - `overflow-x:hidden` על `.filters-row` גרם לדפדפן לחשב `overflow-y: auto`. השורה הפכה למיכל גלילה אנכי שחתך את הדרופדאון, ולכן הוא נראה רק בגלילה בתוך אזור צר שלא נועד לכך. הוחלף ב-`overflow-x: clip`, עם `hidden` כנפילה-לאחור. - הדרופדאון היה מעוגן לעמודה כולה ולא לתיבת הקלט. הועבר לתוך `.search-box-wrapper`, ועכשיו רוחבו וקצהו זהים לתיבה. בנוסף: `background:#fff` הקשיח הוחלף בטוקני `--solid-surface-*`. הם נמדדו על כל שבע ערכות הנושא — ניגודיות 17.7 בכולן, כולל `custom`, שבו `--bg-secondary` אינו מוגדר כלל והתיבה הייתה יוצאת שקופה. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RgnHZBJaH4VhdFFgwYZKLr --- config.py | 10 +++ docs/configuration.rst | 1 + docs/environment-variables.rst | 6 ++ docs/workflows/search-flow.rst | 29 ++++++++ search_engine.py | 26 +++++++- services/config_inspector_service.py | 11 ++++ ...earch_engine_index_built_only_when_used.py | 43 +++++++++++- ...arch_engine_index_not_mutated_by_search.py | 58 ++++++++++++++++ webapp/static/css/global_search.css | 66 ++++++++++++++++--- webapp/static/js/global_search.js | 11 ++-- webapp/templates/files.html | 11 ++-- 11 files changed, 253 insertions(+), 19 deletions(-) create mode 100644 tests/test_search_engine_index_not_mutated_by_search.py diff --git a/config.py b/config.py index bbaffbbde..67d376d73 100644 --- a/config.py +++ b/config.py @@ -412,6 +412,16 @@ class BotConfig(BaseSettings): ), ) + SEARCH_MEMORY_INDEX_EAGER_BUILD: bool = Field( + default=False, + description=( + "Build the in-process SearchIndex on every search, including types that " + "never read it (CONTENT/REGEX/FUZZY). Restores the pre-#3351 behaviour, " + "which kept the index warm for suggest_completions at the cost of a full " + "scan of the user's files on the default WebApp search." + ), + ) + # Pagination defaults SEARCH_PAGE_SIZE: int = Field( default=200, diff --git a/docs/configuration.rst b/docs/configuration.rst index e8c5ce808..c9537dc4c 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -166,6 +166,7 @@ Config via Pydantic Settings - ``AIOHTTP_TIMEOUT_TOTAL`` – Timeout כולל לשיחות aiohttp (שניות) - ``REDIS_MAX_CONNECTIONS`` / ``REDIS_CONNECT_TIMEOUT`` / ``REDIS_SOCKET_TIMEOUT`` – כוונון חיבורי Redis - ``SEARCH_MEMORY_INDEX_ENABLED`` – הפעלת אינדקס החיפוש בזיכרון התהליך (ראו :doc:`/workflows/search-flow`) +- ``SEARCH_MEMORY_INDEX_EAGER_BUILD`` – בניית אותו אינדקס גם בחיפושים שאינם קוראים ממנו - ``SEARCH_PAGE_SIZE`` – גודל דף ברירת מחדל לעימוד חיפוש בצד ה‑DB - ``UI_PAGE_SIZE`` – גודל דף ברירת מחדל לרשימות ב‑UI diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index 97c9372a6..865a4ba95 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -1144,6 +1144,12 @@ - ``500`` - ``300`` - WebApp + * - ``SEARCH_MEMORY_INDEX_EAGER_BUILD`` + - בניית אינדקס החיפוש בזיכרון גם בסוגי חיפוש שאינם קוראים ממנו (``content``/``regex``/``fuzzy``). ``true`` משמר את האינדקס חם עבור ההשלמה האוטומטית, במחיר סריקה מלאה של קבצי המשתמש בכל חיפוש + - לא + - ``false`` + - ``true`` + - WebApp * - ``SEARCH_MEMORY_INDEX_ENABLED`` - הפעלת אינדקס החיפוש בזיכרון התהליך (``SearchIndex``). ``false`` מכבה את בנייתו, וחיפושי ``text`` ו-``function`` נופלים ל-``$text`` של מונגו במקום להישען עליו - לא diff --git a/docs/workflows/search-flow.rst b/docs/workflows/search-flow.rst index c135b5c01..a991453b8 100644 --- a/docs/workflows/search-flow.rst +++ b/docs/workflows/search-flow.rst @@ -141,6 +141,35 @@ נופל מהם לחיפוש ``$text`` ישירות במונגו — כלומר שני הסוגים ממשיכים להחזיר תוצאות, בלי שתי היכולות שלמעלה. +.. note:: + + **צרכן שלישי, עקיף: ההשלמה האוטומטית.** ``suggest_completions`` קורא את + האינדקס דרך ``_get_ready_index``, שבמכוון **אינו** בונה אותו — ההשלמה רצה + תוך כדי הקלדה, ובניית אינדקס שם הייתה יקרה מדי. לכן היא נהנתה מאינדקס + שנבנה כתופעת לוואי של חיפוש אחר. משהוסרה הבנייה מ-``CONTENT``, ההשלמה + מאבדת מילים מתוך תוכן הקבצים ושמות פונקציות; שמות קבצים, תגיות ושפות + מגיעים מ-``autocomplete_manager`` ומ-``$text`` וממשיכים לעבוד. + + ``SEARCH_MEMORY_INDEX_EAGER_BUILD=true`` מחזיר את החימום ואיתו את ההשלמה + המלאה, במחיר הסריקה. שני המתגים מצטרפים כך: + + .. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - ``SEARCH_MEMORY_INDEX_ENABLED`` + - ``..._EAGER_BUILD`` + - התנהגות + * - ``false`` + - לא רלוונטי + - אין אינדקס בזיכרון בכלל + * - ``true`` (ברירת מחדל) + - ``false`` (ברירת מחדל) + - אינדקס רק ל-``TEXT``/``FUNCTION`` + * - ``true`` + - ``true`` + - אינדקס בכל חיפוש; ההשלמה מקבלת אותו חם + מבנה האינדקס: .. code-block:: python diff --git a/search_engine.py b/search_engine.py index 2c875b768..dff586345 100644 --- a/search_engine.py +++ b/search_engine.py @@ -657,6 +657,21 @@ def _memory_index_enabled() -> bool: return bool(getattr(config, "SEARCH_MEMORY_INDEX_ENABLED", True)) +def _memory_index_eager_build() -> bool: + """האם לבנות את האינדקס גם בחיפושים שאינם קוראים ממנו. + + ברירת המחדל ``False``: ``CONTENT``, ``REGEX`` ו-``FUZZY`` אינם משלמים על + בנייה שאיש לא יקרא את תוצאתה. + + ``True`` מחזיר את ההתנהגות הקודמת. היא לא הייתה חסרת ערך: ``suggest_completions`` + קורא את האינדקס דרך ``_get_ready_index``, שבמכוון **אינו** בונה אותו בעצמו — + ולכן ההשלמה נהנתה מאינדקס שחיפוש ``CONTENT`` בנה כתופעת לוואי. עם ``False`` + ההשלמה מאבדת מילים מתוך תוכן הקבצים ושמות פונקציות; שמות קבצים, תגיות ושפות + מגיעים ממקור אחר וממשיכים לעבוד. + """ + return bool(getattr(config, "SEARCH_MEMORY_INDEX_EAGER_BUILD", False)) + + class SearchIndex: """אינדקס חיפוש לביצועים טובים יותר""" @@ -877,6 +892,12 @@ def search(self, user_id: int, query: str, search_type: SearchType = SearchType. pass return [] + # חימום מקדים של האינדקס, גם עבור סוגים שאינם קוראים ממנו. + # כבוי כברירת מחדל; מודלק רק כדי להשאיר אותו חם עבור + # suggest_completions (ראו _memory_index_eager_build). + if _memory_index_eager_build(): + self._get_index_for_search(user_id) + # ביצוע החיפוש לפי סוג. # # האינדקס בזיכרון נבנה רק בענפים שקוראים ממנו: TEXT ו-FUNCTION. @@ -954,7 +975,10 @@ def _text_search(self, query: str, index: SearchIndex, user_id: int) -> List[Sea file_scores: Dict[str, float] = defaultdict(float) for word in query_words: - matching_files = index.word_index.get(word, set()) + # עותק, לא הקבוצה השמורה: ``get`` מחזיר את הקבוצה עצמה כשהמילה קיימת, + # וה-``update`` שלמטה היה מזליג את התאמות ה-prefix לתוך האינדקס לצמיתות — + # ומאותו רגע הן נספרות כהתאמות מדויקות ומקבלות 2.0 במקום 1.0. + matching_files = set(index.word_index.get(word, ())) # חיפוש חלקי (prefix matching) for indexed_word, files in index.word_index.items(): diff --git a/services/config_inspector_service.py b/services/config_inspector_service.py index b2cfc163d..cb695e825 100644 --- a/services/config_inspector_service.py +++ b/services/config_inspector_service.py @@ -2672,6 +2672,17 @@ class ConfigService: ), category="features", ), + "SEARCH_MEMORY_INDEX_EAGER_BUILD": ConfigDefinition( + key="SEARCH_MEMORY_INDEX_EAGER_BUILD", + services=("webapp",), + default="false", + description=( + "בניית אינדקס החיפוש בזיכרון גם בחיפושים שאינם קוראים ממנו " + "(תוכן/Regex/מטושטש). דלוק = ההתנהגות שלפני PR #3351, ששמרה את " + "האינדקס חם עבור ההשלמה האוטומטית במחיר סריקה מלאה בכל חיפוש" + ), + category="features", + ), "SEARCH_PAGE_SIZE": ConfigDefinition( key="SEARCH_PAGE_SIZE", services=("webapp", "bot", "webserver"), diff --git a/tests/test_search_engine_index_built_only_when_used.py b/tests/test_search_engine_index_built_only_when_used.py index 1c5abd8d8..22ea65930 100644 --- a/tests/test_search_engine_index_built_only_when_used.py +++ b/tests/test_search_engine_index_built_only_when_used.py @@ -142,10 +142,49 @@ def test_the_log_line_the_user_sees_is_gone_for_a_content_search(stub_db, caplog engine = se.AdvancedSearchEngine() with caplog.at_level("INFO", logger="search_engine"): - engine.search(user_id=6865105071, query="needle", search_type=se.SearchType.CONTENT) + engine.search(user_id=1, query="needle", search_type=se.SearchType.CONTENT) assert "בונה אינדקס חיפוש" not in caplog.text caplog.clear() with caplog.at_level("INFO", logger="search_engine"): - engine.search(user_id=6865105071, query="needle", search_type=se.SearchType.TEXT) + engine.search(user_id=1, query="needle", search_type=se.SearchType.TEXT) assert "בונה אינדקס חיפוש" in caplog.text + + +def test_eager_build_restores_the_warm_index_for_suggestions(stub_db, monkeypatch): + """``SEARCH_MEMORY_INDEX_EAGER_BUILD=true`` מחזיר את ההתנהגות שלפני השינוי. + + זו הסיבה שהמתג קיים: ``suggest_completions`` קורא את האינדקס דרך + ``_get_ready_index``, שבמכוון אינו בונה אותו. לפני השינוי חיפוש ``CONTENT`` + בנה אותו כתופעת לוואי, וההשלמה נהנתה מזה. הטסט נועל את שני הכיוונים. + """ + engine = se.AdvancedSearchEngine() + + # ברירת מחדל: אין חימום, ולכן אין ממה להציע + engine.search(user_id=1, query="needle", search_type=se.SearchType.CONTENT) + assert engine.indexes == {} + assert engine.suggest_completions(1, "need", limit=10) == [] + + # מתג דלוק: האינדקס חם, וההשלמה מוצאת מילים מתוך תוכן הקובץ + monkeypatch.setattr(se.config, "SEARCH_MEMORY_INDEX_EAGER_BUILD", True, raising=False) + warm = se.AdvancedSearchEngine() + warm.search(user_id=1, query="needle", search_type=se.SearchType.CONTENT) + assert list(warm.indexes) == [1] + assert "needle" in warm.suggest_completions(1, "need", limit=10) + + +def test_the_master_switch_wins_over_eager_build(rebuild_spy, stub_db, monkeypatch): + """כיבוי מלא גובר על חימום מקדים — אין נתיב שבו השילוב בונה אינדקס.""" + monkeypatch.setattr(se.config, "SEARCH_MEMORY_INDEX_ENABLED", False, raising=False) + monkeypatch.setattr(se.config, "SEARCH_MEMORY_INDEX_EAGER_BUILD", True, raising=False) + engine = se.AdvancedSearchEngine() + + engine.search(user_id=1, query="needle", search_type=se.SearchType.CONTENT) + + assert rebuild_spy == [] + assert engine.indexes == {} + + +def test_eager_build_is_off_when_the_config_does_not_carry_it(): + assert not hasattr(se.config, "SEARCH_MEMORY_INDEX_EAGER_BUILD") + assert se._memory_index_eager_build() is False diff --git a/tests/test_search_engine_index_not_mutated_by_search.py b/tests/test_search_engine_index_not_mutated_by_search.py new file mode 100644 index 000000000..607e554d1 --- /dev/null +++ b/tests/test_search_engine_index_not_mutated_by_search.py @@ -0,0 +1,58 @@ +"""חיפוש טקסט אינו רשאי לשנות את האינדקס שהוא קורא ממנו. + +``_text_search`` שלף את קבוצת הקבצים של מילה עם ``word_index.get(word, set())`` +— שמחזיר את **הקבוצה השמורה עצמה** כשהמילה קיימת — ואז הרחיב אותה ב-``update`` +עם התאמות ה-prefix. מאותו רגע ולכל חיי האינדקס, התאמות חלקיות נספרות כהתאמות +מדויקות ומקבלות ניקוד 2.0 במקום 1.0, כלומר הדירוג נשחק בכל חיפוש נוסף. +""" + +import search_engine as se + + +def _index_with(word_map): + index = se.SearchIndex() + for word, keys in word_map.items(): + index.word_index[word].update(keys) + index.last_update = se.datetime.now(se.timezone.utc) + return index + + +def test_text_search_does_not_pollute_the_stored_word_set(monkeypatch): + monkeypatch.setattr( + se, "db", type("_DB", (), {"get_latest_version": lambda *_a, **_k: None})(), + raising=False, + ) + index = _index_with({"need": {"1:a.py"}, "needle": {"1:b.py"}}) + before = set(index.word_index["need"]) + + engine = se.AdvancedSearchEngine() + engine._text_search("need", index, user_id=1) + + assert index.word_index["need"] == before, ( + "התאמת prefix דלפה לתוך קבוצת ההתאמות המדויקות של המילה" + ) + + +def test_repeated_searches_keep_scoring_exact_matches_higher(monkeypatch): + """הניקוד יציב בין חיפושים — מדויק 2.0, חלקי 1.0, גם בפעם השנייה.""" + docs = { + "1:a.py": {"file_name": "a.py", "code": "need", "programming_language": "python", + "tags": [], "created_at": se.datetime.now(se.timezone.utc), + "updated_at": se.datetime.now(se.timezone.utc), "version": 1}, + "1:b.py": {"file_name": "b.py", "code": "needle", "programming_language": "python", + "tags": [], "created_at": se.datetime.now(se.timezone.utc), + "updated_at": se.datetime.now(se.timezone.utc), "version": 1}, + } + + class _DB: + def get_latest_version(self, user_id, file_name): + return dict(docs[f"{user_id}:{file_name}"]) + + monkeypatch.setattr(se, "db", _DB(), raising=False) + index = _index_with({"need": {"1:a.py"}, "needle": {"1:b.py"}}) + engine = se.AdvancedSearchEngine() + + first = {r.file_name: r.relevance_score for r in engine._text_search("need", index, 1)} + second = {r.file_name: r.relevance_score for r in engine._text_search("need", index, 1)} + + assert first == second, "הניקוד השתנה בין שני חיפושים זהים על אותו אינדקס" diff --git a/webapp/static/css/global_search.css b/webapp/static/css/global_search.css index 8fd2297dc..0d587670d 100644 --- a/webapp/static/css/global_search.css +++ b/webapp/static/css/global_search.css @@ -1,14 +1,59 @@ +/* --- הצעות השלמה לחיפוש --- */ +/* טוקני רכיב (Level 3) עם נפילה-לאחור לטוקנים הסמנטיים, לפי + docs/webapp/theming_and_css.rst. אין כאן HEX: הרקע חייב להיות אטום בכל + ערכת נושא, כי התיבה מרחפת מעל תוכן. */ #searchSuggestions{ + /* משפחת --solid-surface-* היא משטח האטום של הפרויקט לתפריטים צפים, ובה + משתמש גם תפריט השפות באותו עמוד. נמדדה על כל שבע ערכות הנושא: ניגודיות + 17.7 בכולן, וגם ב-custom — שבו --bg-secondary אינו מוגדר כלל והתיבה + הייתה יוצאת שקופה. */ + --suggestions-bg: var(--solid-surface-bg); + --suggestions-text: var(--solid-surface-text); + --suggestions-border: var(--solid-surface-border); + /* גוון ההדגשה נגזר מהמשטח עצמו, ולכן נשאר נראה על משטח בהיר או כהה כאחד */ + --suggestions-hover: color-mix(in srgb, var(--suggestions-text) 8%, var(--suggestions-bg)); + max-height:300px; overflow-y:auto; - position:absolute; /* עיגון לתיבת הקלט כדי שלא "יברח" שמאלה */ + overscroll-behavior: contain; /* גלילה בתוך הרשימה לא תגלגל את העמוד */ + position:absolute; /* נצמד ל-.search-box-wrapper, כלומר לתיבת הקלט עצמה */ top:calc(100% + 6px); - left:0; right:0; + inset-inline:0; /* לוגי: עובד ב-RTL וב-LTR כאחד */ z-index:1000; - background:#fff; - border:1px solid rgba(0,0,0,0.1); + background: var(--suggestions-bg); + color: var(--suggestions-text); + border:1px solid var(--suggestions-border); border-radius:8px; - box-shadow:0 4px 12px rgba(0,0,0,0.15); + box-shadow: var(--solid-surface-shadow); + padding-block: 0.25rem; +} + +#searchSuggestions[hidden]{ display:none; } + +/* הוובאפ אינו טוען את ה-CSS של Bootstrap, ולכן list-group-item לא עיצב כאן + דבר: ההצעות היו אלמנטים inline בלי ריפוד, וחמש מהן נדחסו לשורה אחת. */ +.search-suggestion{ + display:block; + padding:0.55rem 0.85rem; + color: var(--suggestions-text); + text-decoration:none; + line-height:1.4; + overflow-wrap:anywhere; /* הצעה ארוכה נשברת ולא מרחיבה את התיבה */ + border-block-end:1px solid var(--suggestions-border); + transition: background .15s ease; +} + +.search-suggestion:last-child{ border-block-end:none; } + +.search-suggestion:hover, +.search-suggestion:focus-visible{ + background: var(--suggestions-hover); + color: var(--suggestions-text); + text-decoration:none; +} + +@media (prefers-reduced-motion: reduce){ + .search-suggestion{ transition:none; } } /* --- תיבות חיפוש מעוצבות בסגנון "אוסף הקהילה" --- */ @@ -258,14 +303,19 @@ flex-wrap:wrap; gap:1rem; width:100%; - /* בלימה של גלילה אופקית במקרי קצה */ + /* בלימה של גלילה אופקית במקרי קצה. + overflow-x:hidden אסור כאן: הדפדפן מחשב אז overflow-y ל-auto, השורה + הופכת למיכל גלילה אנכי, והדרופדאון של ההצעות נחתך בתוכה. clip בולם + בדיוק אותו דבר בלי להפוך את הציר האנכי. ההצהרה הראשונה היא נפילה-לאחור + לדפדפנים שאינם מכירים clip. */ overflow-x: hidden; + overflow-x: clip; } -/* עיגון הסוגסטיות לאיזור תיבת החיפוש */ +/* עמודת החיפוש */ .search-and-filters .col-lg-8, .search-and-filters .col-md-8{ - position:relative; /* כדי שה-#searchSuggestions יתמקם ביחס לעמודה */ + position:relative; flex:1 1 65%; min-width:300px; max-width:100%; diff --git a/webapp/static/js/global_search.js b/webapp/static/js/global_search.js index adfe3dc23..26368bf9b 100644 --- a/webapp/static/js/global_search.js +++ b/webapp/static/js/global_search.js @@ -120,7 +120,7 @@ const pagination = document.getElementById('searchPagination'); if (input) { input.value = ''; input.focus(); } - if (suggestions) { suggestions.style.display = 'none'; suggestions.innerHTML = ''; } + if (suggestions) { suggestions.hidden = true; suggestions.innerHTML = ''; } if (clearBtn) { clearBtn.style.display = 'none'; } // אפס סלקטים לערכי ברירת המחדל (תוכן / 10 / רלוונטיות). @@ -405,7 +405,10 @@ items.forEach(function(s){ const a = document.createElement('a'); a.href = '#'; - a.className = 'list-group-item list-group-item-action'; + // מחלקה של הפרויקט, לא של Bootstrap: הוובאפ אינו טוען את ה-CSS של + // Bootstrap, ולכן list-group-item לא עיצב כאן דבר וההצעות נדבקו זו לזו. + a.className = 'search-suggestion'; + a.setAttribute('role', 'option'); a.textContent = String(s || ''); a.addEventListener('click', function(e){ e.preventDefault(); @@ -415,9 +418,9 @@ }); box.appendChild(a); }); - box.style.display = 'block'; + box.hidden = false; } - function hideSuggestions(){ const box = $('searchSuggestions'); if (box) box.style.display='none'; } + function hideSuggestions(){ const box = $('searchSuggestions'); if (box) box.hidden = true; } // אייקון השפה מגיע מ-window.langIcon (base.html) — מקור אמת אחד לכל האפליקציה function fileIcon(lang){ diff --git a/webapp/templates/files.html b/webapp/templates/files.html index d44842334..685f8a3af 100644 --- a/webapp/templates/files.html +++ b/webapp/templates/files.html @@ -32,6 +32,13 @@

הקבצים שלי

aria-label="נקה חיפוש"> + +
- From 96e8a54d1d94010d6592305072b7cc68bc96c66d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 21:01:34 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(webapp):=20=D7=A8=D7=A9=D7=99=D7=9E?= =?UTF-8?q?=D7=AA=20=D7=94=D7=94=D7=A6=D7=A2=D7=95=D7=AA=20=D7=A0=D7=9B?= =?UTF-8?q?=D7=A0=D7=A1=D7=94=20=D7=9E=D7=AA=D7=97=D7=AA=20=D7=9C=D7=9B?= =?UTF-8?q?=D7=A8=D7=98=D7=99=D7=A1=20=D7=94=D7=91=D7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit אחרי שההצעות קיבלו גובה אמיתי, התגלה שהרשימה נחתכת מאחורי הכרטיס הבא בעמוד. נמדד ב-Chromium: מתוך חמש הצעות, שתיים היו מכוסות על ידי תיבת החיפוש שבכרטיס שאחריו. השורש: ל-`.glass-card` יש `backdrop-filter: blur(20px)`, וזה יוצר stacking context. לכן `z-index:1000` של רשימת ההצעות כלוא בגבולות הכרטיס המארח, והכרטיס עצמו הוא `position:static; z-index:auto` — כלומר הכרטיס הבא ב-DOM נצבע מעליו, ושום z-index פנימי אינו יכול לגבור על כך. התיקון מרים את הכרטיס המארח עצמו (`position:relative; z-index:3`), כי זו הרמה שבה ההכרעה באמת מתקבלת. הערך נמוך בכוונה: מודאלים ותפריטים גלובליים יושבים ב-1000 ומעלה וממשיכים לגבור. אימות: 12 הצעות, שתי ערכות נושא, שני רוחבים — אפס פריטים מכוסים בכל השילובים. שתי מוטציות מוכיחות שהמדידה מסוגלת ליפול: הסרת ה-z-index מהכרטיס מחזירה כיסוי של עד ארבעה פריטים, והחזרת `overflow-x:hidden` לשורה מחזירה כיסוי של עד שבעה. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RgnHZBJaH4VhdFFgwYZKLr --- webapp/static/css/global_search.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/webapp/static/css/global_search.css b/webapp/static/css/global_search.css index 0d587670d..cd1e6e400 100644 --- a/webapp/static/css/global_search.css +++ b/webapp/static/css/global_search.css @@ -277,6 +277,15 @@ margin:0 auto 1rem; padding:1rem; box-sizing:border-box; + + /* הכרטיס מארח תפריטים צפים (הצעות ההשלמה, ותפריט השפות). ל-.glass-card יש + backdrop-filter, שיוצר stacking context — ולכן ה-z-index של תפריט בתוכו + כלוא בגבולות הכרטיס, והכרטיס הבא ב-DOM נצבע מעליו. הרמת הכרטיס עצמו היא + מה שמוציא את התפריטים מעל השכנים. + הערך נמוך בכוונה: מודאלים ותפריטים גלובליים חיים ב-1000 ומעלה וממשיכים + לגבור עליו. */ + position:relative; + z-index:3; } /* סלקטים בחיפוש הגלובלי – עיצוב זהה לסלקטים מתחת "שאר קבצים" */