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"
],