diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 38550db..4c8aab5 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -24,11 +24,35 @@ "subtasks": [], "dependencies": [], "createdAt": "2026-04-24T15:47:00Z" + }, + { + "id": 3, + "title": "feat(kb/search): LLM query expansion so ranked-by-title docs don't crowd everything else out (shira-hermes)", + "description": "User reports searching 'מהי תקנה 37' returns only the תקנה 37 circular and the law — never ספר הליקויים, even though that's where the underlying medical content lives. Root cause is a single-shot retrieval where any document whose title contains the query terms wins by a huge margin. Fix: ask the LLM (via ai-gateway) for up to 3 alternative phrasings, retrieve candidates for the original + each variant in parallel, merge by chunk_id keeping best RRF score, then rerank the union against the ORIGINAL query. After fix: src 29 (ספר הליקויים) shows up with 2 hits in the same query.", + "status": "done", + "priority": "high", + "details": "Lives in espocrm-extensions/shira-hermes commit 1441a41. /kb/search now has expand=true by default, agent path (search_insurance_kb tool) keeps expand=false because the agent already does multi-query via tool calls.", + "testStrategy": "POST /kb/search {query:'מהי תקנה 37', top_k:8} should return hits from at least 2 distinct source_ids, including src 29 (ספר הליקויים) and src 26 (תקנה 37 חוזר).", + "subtasks": [], + "dependencies": [], + "createdAt": "2026-04-25T10:00:00Z" + }, + { + "id": 4, + "title": "fix: ask survives view switch via module-level promise + sessionStorage", + "description": "User reports asking Shira a question and switching to a different EspoCRM view loses the in-flight request — coming back shows a blank screen as if nothing was asked. Cause: the Espo.Ajax promise was view-bound, so handlers fired against a detached DOM after re-mount. Fix: hoist {question, promise, startedAt} to a module-level _activeAsk; afterRender re-attaches handlers and resumes the progress UI with the original startedAt; completed answers persist to sessionStorage and replay on remount or a hard reload.", + "status": "done", + "priority": "high", + "details": "All client-side, files/client/custom/modules/knowledge-base/src/views/kb/index.js. State is intentionally session-scoped — kept in sessionStorage so the next browser session starts clean.", + "testStrategy": "Ask a question; while the spinner shows, click Contacts (or any other navbar item); wait until the request would have finished; click back to בסיס ידע — the answer (or active spinner with correct elapsed-time) should be there.", + "subtasks": [], + "dependencies": [], + "createdAt": "2026-04-25T10:00:00Z" } ], "metadata": { "created": "2026-04-24T15:47:00Z", - "updated": "2026-04-24T15:47:00Z", + "updated": "2026-04-25T10:30:00Z", "description": "KnowledgeBase extension tasks" } } diff --git a/files/client/custom/modules/knowledge-base/src/views/kb/index.js b/files/client/custom/modules/knowledge-base/src/views/kb/index.js index 613c8e2..28478ac 100644 --- a/files/client/custom/modules/knowledge-base/src/views/kb/index.js +++ b/files/client/custom/modules/knowledge-base/src/views/kb/index.js @@ -1,5 +1,34 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { + // Module-level state survives view unmount/remount within a single + // SPA session so a user can fire an ask, navigate elsewhere, and come + // back to the answer (or to the still-spinning request) without losing + // it. sessionStorage backs the last completed answer so a hard reload + // also recovers it. None of this leaks to the next browser session — + // ask history is intentionally ephemeral. + let _activeAsk = null; // {question, promise} while a request is in flight + let _lastAsk = null; // {question, text, sources, completedAt} after success + + const SS_KEY = 'kb-last-ask'; + + function _loadLastFromSession() { + try { + const raw = sessionStorage.getItem(SS_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.question === 'string') return parsed; + } catch (e) { /* corrupted, ignore */ } + return null; + } + function _saveLastToSession(entry) { + try { sessionStorage.setItem(SS_KEY, JSON.stringify(entry)); } + catch (e) { /* quota or disabled, fine */ } + } + function _clearLastInSession() { + try { sessionStorage.removeItem(SS_KEY); } catch (e) { /* ignore */ } + } + if (!_lastAsk) _lastAsk = _loadLastFromSession(); + return Dep.extend({ template: 'knowledge-base:kb/index', @@ -68,6 +97,25 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { } else if (this._sources) { this.renderSourcesList(); } + + // Recover ask state across view remounts (user navigated away and + // back). _activeAsk wins — there's still a request in flight, so + // re-show the progress panel and re-bind handlers. Otherwise + // _lastAsk replays the most recent answer in this browser session. + if (this.mode === 'ask') { + if (_activeAsk) { + const $input = this.$el.find('input[data-name="query"]'); + if ($input.length) $input.val(_activeAsk.question); + this.setLoading(true); + this.startAskProgress(_activeAsk.startedAt); + this._attachAskHandlers(_activeAsk); + } else if (_lastAsk) { + const $input = this.$el.find('input[data-name="query"]'); + if ($input.length) $input.val(_lastAsk.question); + this.renderAskAnswer(_lastAsk.text, _lastAsk.sources); + } + } + // Focus the input on every render so users can start typing right away. const $input = this.$el.find('input[data-name="query"]'); if ($input.length) $input.trigger('focus'); @@ -102,30 +150,65 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { }, runAsk: function (message) { + // A new ask invalidates whatever last-result was on screen. + _lastAsk = null; + _clearLastInSession(); + this.setLoading(true); - this.startAskProgress(); // Ask goes through the full agent loop (search + rerank + LLM), // which routinely takes 30-90s. The default jQuery Ajax timeout // is too short; override it here so the browser doesn't give up // before shira-hermes responds. - Espo.Ajax.postRequest('KnowledgeBase/action/ask', { + const promise = Espo.Ajax.postRequest('KnowledgeBase/action/ask', { message: message, - }, {timeout: 240000}).then(res => { - this.stopAskProgress(); - this.setLoading(false); - this.renderAskAnswer(res.text || '', res.sources || []); + }, {timeout: 240000}); + _activeAsk = {question: message, promise: promise, startedAt: Date.now()}; + this.startAskProgress(_activeAsk.startedAt); + this._attachAskHandlers(_activeAsk); + }, + + // Wire .then/.catch on a (possibly module-level) ask promise to + // whichever view is currently mounted. Called from runAsk for the + // initial bind and from afterRender when the user navigates back to + // the KB view while an earlier request is still in flight. + _attachAskHandlers: function (ask) { + const self = this; + ask.promise.then(res => { + const entry = { + question: ask.question, + text: res.text || '', + sources: res.sources || [], + completedAt: Date.now(), + }; + _lastAsk = entry; + _saveLastToSession(entry); + if (_activeAsk === ask) _activeAsk = null; + if (self.$el && self.$el.length) { + self.stopAskProgress(); + self.setLoading(false); + if (self.mode === 'ask') { + self.renderAskAnswer(entry.text, entry.sources); + } + } }).catch(err => { - this.stopAskProgress(); - this.setLoading(false); - this.showError(err); + if (_activeAsk === ask) _activeAsk = null; + if (self.$el && self.$el.length) { + self.stopAskProgress(); + self.setLoading(false); + self.showError(err); + } }); }, - startAskProgress: function () { + startAskProgress: function (startedAt) { // Show a live elapsed-time indicator so users know the request // is still running rather than stuck. Typical answers arrive in - // 30-90s; we warn after 2 minutes. + // 30-90s; we warn after 2 minutes. `startedAt` is passed in so + // a request that began before a view re-mount keeps showing the + // accumulated elapsed time, not 0. if (this.mode !== 'ask') return; + this.stopAskProgress(); + startedAt = startedAt || Date.now(); const $results = this.$el.find('.kb-results'); $results.html( '
' + @@ -144,8 +227,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { '
' + '' ); - const startedAt = Date.now(); - this._askTimer = setInterval(() => { + const tick = () => { const sec = Math.round((Date.now() - startedAt) / 1000); const $elapsed = this.$el.find('.kb-elapsed'); if ($elapsed.length) $elapsed.text(sec); @@ -155,7 +237,9 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { 'לוקח יותר זמן מהרגיל. עדיין ממתינים…' ); } - }, 1000); + }; + tick(); + this._askTimer = setInterval(tick, 1000); }, stopAskProgress: function () { diff --git a/manifest.json b/manifest.json index 2a65b37..950749f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,14 +1,14 @@ { "name": "KnowledgeBase", "module": "KnowledgeBase", - "version": "0.1.8", + "version": "0.1.9", "acceptableVersions": [ ">=8.0.0" ], "php": [ ">=8.1" ], - "releaseDate": "2026-04-24", + "releaseDate": "2026-04-25", "author": "klear", "description": "Knowledge Base — Israeli National Insurance law, regulations, and circulars. Hybrid search + ask-shira, powered by shira-hermes KB." } \ No newline at end of file