diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index abb3228..823dd9d 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -37,6 +37,18 @@ "dependencies": [], "createdAt": "2026-04-25T10:00:00Z" }, + { + "id": 7, + "title": "fix: search preview duplicates left card for text sources", + "description": "User report: searching the law (Wikisource source 5) shows the same chunk content in both the left list-card and the right preview pane. The preview adds zero value when the matched source has no PDF — both panes are visually identical (modulo the left's 14em truncation), which reads as a bug. Fixed by fetching the matched chunk plus K=2 surrounding sections from /kb/source/{id}/chunks?around=N&ctx=2 and rendering them stacked: matched section is highlighted with a yellow header (#fff3cd), neighbors render as muted context blocks above and below. Adds a prominent 'פתח ב-Wikisource' button in the panel header, scrolls the matched section into view on render. Race-safe via _previewToken so a quick second click doesn't render stale results.", + "status": "done", + "priority": "high", + "details": "shira-hermes b127caf added chunk_index to /kb/search hits + an `around`/`ctx` query param to /source/{id}/chunks. Espo controller passes them through. Client uses the new fields in showSearchPreview when the hit is a text source.", + "testStrategy": "Search 'מהי תקנה 36'; click the law-kind hit. Right pane: yellow-highlighted ס' 36 in the middle, ס' 35 above (muted), ס' 37 below (muted), Wikisource button in header. Click the תקנה 37 חוזר hit: right pane swaps to PDF iframe at the cited page (PDF path unchanged).", + "subtasks": [], + "dependencies": [], + "createdAt": "2026-04-25T12:30:00Z" + }, { "id": 5, "title": "feat(kb): drag-to-resize splitter (both ask + search)", 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 1f48c6e..387c862 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 @@ -465,23 +465,114 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { return; } - // Text source (law from Wikisource) — show the full chunk content - // and a link to the public source page. + // Text source (law from Wikisource): fetch the matched chunk + + // surrounding sections so the preview pane shows context, not a + // copy of what's already in the left card. While loading, show a + // placeholder; on failure fall back to the chunk-only view. + this._renderSectionContextPlaceholder(h); + const token = this._previewToken; + const sourceId = h.source_id; + const around = (typeof h.chunk_index === 'number') ? h.chunk_index : null; + if (sourceId == null || around == null) { + this._renderSectionContextSingle(h); + return; + } + const self = this; + Espo.Ajax.getRequest('KnowledgeBase/action/chunks', { + sourceId: sourceId, + around: around, + ctx: 2, + }).then(res => { + // Bail if user has clicked a different hit while we were waiting. + if (self._previewToken !== token) return; + self._renderSectionContext(h, res && res.chunks ? res.chunks : []); + }).catch(err => { + console.warn('KB: section-window fetch failed', err); + if (self._previewToken === token) self._renderSectionContextSingle(h); + }); + }, + + _renderSectionContextPlaceholder: function (h) { + this._previewToken = (this._previewToken || 0) + 1; + const $slot = this.$el.find('.kb-preview-slot'); + const title = this.escape(h.title || ''); + $slot.html( + '
' + + '
' + title + + ' — טוען הקשר…' + + '
' + + '
' + + ' ' + + '
' + + '
' + ); + }, + + _renderSectionContextSingle: function (h) { + // Fallback when we can't fetch the window — render just the + // matched chunk, which is at least the full section for the + // statute chunker (1 section = 1 chunk). + this._renderSectionContext(h, [{ + chunk_index: h.chunk_index, + section_ref: h.section_ref || '', + heading_path: h.heading_path || '', + content: h.content || '', + }]); + }, + + _renderSectionContext: function (h, chunks) { + const $slot = this.$el.find('.kb-preview-slot'); const bodyStyle = 'direction:rtl;unicode-bidi:plaintext;white-space:pre-wrap;' + 'font-family:"Segoe UI",Arial,sans-serif;line-height:1.7;text-align:right;'; const srcLink = h.source_url - ? `מקור ב-Wikisource` + ? '' + + ' פתח ב-Wikisource' : ''; - $slot.html(` -
-
${this.escape(h.title || '')}
-
- ${this.escape(h.content || '')} -
- ${srcLink ? '' : ''} -
- `); + // The matched chunk is highlighted; siblings render as muted + // context blocks above and below so the user can read into the + // section without leaving the CRM. + const sectionsHtml = chunks.map(c => { + const isMatch = c.chunk_index === h.chunk_index; + const ref = c.section_ref || c.heading_path || ''; + const headerStyle = isMatch + ? 'background:#fff3cd;border-bottom:1px solid #ffe69c;color:#664d03;font-weight:600;' + : 'background:#f5f5f7;border-bottom:1px solid #e7e7ea;color:#6c757d;'; + return ( + '
' + + '
' + + (isMatch ? 'התאמה — ' : '') + this.escape(ref) + + '
' + + '
' + + this.escape(c.content || '') + + '
' + + '
' + ); + }).join(''); + + $slot.html( + '
' + + '
' + + '
' + this.escape(h.title || '') + '' + + ' — הסעיף בהקשרו' + + '
' + + srcLink + + '
' + + '
' + + sectionsHtml + + '
' + + '
' + ); + + // Scroll the matched chunk into view inside the preview pane. + const $body = $slot.find('.panel-body'); + const $match = $body.find('div[style*="#fff3cd"]').closest('div[style*="border-radius:4px"]'); + if ($match.length && $body.length) { + const offset = $match.position().top - 20; + $body.scrollTop(Math.max(0, offset)); + } }, renderAskAnswer: function (text, sources) { diff --git a/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php b/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php index 7b7849b..0a325b4 100644 --- a/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php +++ b/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php @@ -73,7 +73,12 @@ class KnowledgeBase throw new BadRequest('sourceId (numeric) is required.'); } - return $this->getService()->sourceChunks((int) $sourceId); + $around = $request->getQueryParam('around'); + $ctx = $request->getQueryParam('ctx'); + $aroundInt = ($around !== null && is_numeric($around)) ? (int) $around : null; + $ctxInt = ($ctx !== null && is_numeric($ctx)) ? (int) $ctx : 2; + + return $this->getService()->sourceChunks((int) $sourceId, $aroundInt, $ctxInt); } public function postActionAsk(Request $request, Response $response): array diff --git a/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php b/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php index 4f99872..6e8d5a4 100644 --- a/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php +++ b/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php @@ -48,9 +48,13 @@ class KnowledgeBaseService return $this->get('/kb/sources'); } - public function sourceChunks(int $sourceId): array + public function sourceChunks(int $sourceId, ?int $around = null, int $ctx = 2): array { - return $this->get('/kb/source/' . $sourceId . '/chunks'); + $path = '/kb/source/' . $sourceId . '/chunks'; + if ($around !== null) { + $path .= '?around=' . $around . '&ctx=' . $ctx; + } + return $this->get($path); } public function ask(string $message, ?string $conversationId, array $history): array diff --git a/manifest.json b/manifest.json index bffa9db..2d6f7d3 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "KnowledgeBase", "module": "KnowledgeBase", - "version": "0.1.10", + "version": "0.1.11", "acceptableVersions": [ ">=8.0.0" ],