feat(kb): split-view PDF jump-to-page for both ask and search modes

Search and ask modes now render results as a two-column layout: a left
column with ranked hit cards (search) or Shira's answer + source picker
(ask), and a right column with an iframe showing the source PDF scrolled
to page_number. Clicking a hit (search) or source pill (ask) swaps the
iframe's src, so users can verify a quote against the original PDF
without leaving the KB tab.

- search: renderSearchResults lays out results as panel cards on the
  left (with kind + title + section + "עמ׳ N" label); the top hit is
  pre-selected and its PDF loads on the right. Clicking any card
  re-highlights it and swaps the preview. Law-kind hits (Wikisource
  text) gracefully fall back to a chunk-text panel with a Wikisource
  link so the right pane never 404s on a text source.
- ask: renderAskAnswer dedups /kb/ask's sources[] by source_id,
  collects all cited pages per source, and renders a picker row plus
  per-source page-jump buttons. First source's first page loads on
  initial render; buttons swap the iframe without re-running the query.

Depends on shira-hermes commits e534709 + 1ca1cc0 (chunker page_number
propagation + null-byte strip) — without them, every PDF collapses to
page 1 in the DB and the jump links are cosmetic.

Refs Task Master #1
This commit is contained in:
2026-04-24 15:49:29 +00:00
parent 34ad5cd023
commit 243ae2353f
9 changed files with 908 additions and 26 deletions
@@ -113,7 +113,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
}, {timeout: 240000}).then(res => {
this.stopAskProgress();
this.setLoading(false);
this.renderAskAnswer(res.text || '');
this.renderAskAnswer(res.text || '', res.sources || []);
}).catch(err => {
this.stopAskProgress();
this.setLoading(false);
@@ -172,50 +172,252 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
return;
}
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר'};
const rows = hits.map(h => {
const self = this;
const isPdfBacked = h => h && (h.kind === 'regulation' || h.kind === 'circular');
// Left column: stacked hit cards. Click → preview on the right.
const bodyStyle =
'direction:rtl;unicode-bidi:plaintext;white-space:pre-wrap;' +
'font-family:"Segoe UI",Arial,sans-serif;line-height:1.6;text-align:right;' +
'max-height:14em;overflow:hidden;';
const listHtml = hits.map((h, i) => {
const kind = kindHe[h.kind] || h.kind;
const ident = h.identifier ? ' ' + this.escape(h.identifier) : '';
const path = h.heading_path || h.section_ref || '';
const pub = h.published_at ? ' · פורסם ' + h.published_at : '';
// Content is rendered inside a white-space:pre-wrap block —
// no need to convert \n to <br>; that would double-break.
const content = this.escape(h.content || '');
const srcLink = h.source_url
? `<a href="${this.escape(h.source_url)}" target="_blank" rel="noopener">מקור</a>`
: '';
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 pageLabel = (isPdfBacked(h) && h.page_number)
? ` · <span class="label label-default">עמ׳ ${h.page_number}</span>` : '';
const activeClass = i === 0 ? 'panel-primary' : 'panel-default';
return `
<div class="kb-hit panel panel-default" style="margin-bottom:10px;">
<div class="panel-heading" style="direction:rtl;text-align:right;">
<div class="kb-hit panel ${activeClass}" data-hit-idx="${i}"
style="margin-bottom:8px;cursor:pointer;">
<div class="panel-heading" style="direction:rtl;text-align:right;padding:6px 10px;">
<strong>[${kind}${ident}]</strong> ${this.escape(h.title || '')}
${path ? ' — <span class="text-muted">' + this.escape(path) + '</span>' : ''}
<span class="text-muted small">${pub}</span>
<span class="text-muted small">${pub}</span>${pageLabel}
</div>
<div class="panel-body" style="${bodyStyle}">${content}</div>
${srcLink ? '<div class="panel-footer small">' + srcLink + '</div>' : ''}
<div class="panel-body" style="${bodyStyle}">${this.escape(h.content || '')}</div>
</div>`;
}).join('');
$results.html(rows);
$results.html(`
<div class="row" style="margin:0;">
<div class="col-md-5" style="padding-right:0;max-height:86vh;overflow-y:auto;">
${listHtml}
</div>
<div class="col-md-7 kb-search-preview" style="padding-left:0;">
<div class="kb-preview-slot"></div>
</div>
</div>
`);
// Cache hits on the view so click handlers can reach them.
this._searchHits = hits;
this.showSearchPreview(0);
this.$el.find('.kb-hit').off('click').on('click', function () {
const idx = parseInt($(this).data('hit-idx'), 10);
if (Number.isNaN(idx)) return;
self.$el.find('.kb-hit')
.removeClass('panel-primary').addClass('panel-default');
$(this).removeClass('panel-default').addClass('panel-primary');
self.showSearchPreview(idx);
});
},
renderAskAnswer: function (text) {
showSearchPreview: function (idx) {
const hits = this._searchHits || [];
const h = hits[idx];
const $slot = this.$el.find('.kb-preview-slot');
if (!h) { $slot.empty(); return; }
const isPdf = h.kind === 'regulation' || h.kind === 'circular';
if (isPdf && h.source_id) {
const page = h.page_number || 1;
const url = '?entryPoint=KnowledgeBasePdf&sourceId='
+ encodeURIComponent(h.source_id)
+ '#page=' + encodeURIComponent(page);
$slot.html(`
<div style="direction:rtl;text-align:right;margin-bottom:6px;">
<span class="text-muted small">מציג עמ׳ ${page} של המסמך</span>
</div>
<iframe class="kb-pdf-iframe"
src="${url}"
style="width:100%;height:82vh;border:1px solid #ddd;background:#fafafa;"
title="מקור"></iframe>
`);
return;
}
// Text source (law from Wikisource) — show the full chunk content
// and a link to the public source page.
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
? `<a href="${this.escape(h.source_url)}" target="_blank" rel="noopener">מקור ב-Wikisource</a>`
: '';
$slot.html(`
<div class="panel panel-default" style="direction:rtl;text-align:right;">
<div class="panel-heading">${this.escape(h.title || '')}</div>
<div class="panel-body" style="${bodyStyle};max-height:80vh;overflow-y:auto;">
${this.escape(h.content || '')}
</div>
${srcLink ? '<div class="panel-footer small">' + srcLink + '</div>' : ''}
</div>
`);
},
renderAskAnswer: function (text, sources) {
const $results = this.$el.find('.kb-results');
sources = sources || [];
// Shira replies in markdown; render through EspoCRM's markdown
// helper when available, otherwise fall back to escaped text.
let html;
let answerHtml;
try {
const helper = this.getHelper && this.getHelper();
if (helper && typeof helper.transformMarkdownText === 'function') {
html = helper.transformMarkdownText(text || '');
if (html && html.toString) html = html.toString();
answerHtml = helper.transformMarkdownText(text || '');
if (answerHtml && answerHtml.toString) answerHtml = answerHtml.toString();
}
} catch (e) { /* fall through */ }
if (!html) {
html = '<div style="white-space:pre-wrap;">' + this.escape(text || '') + '</div>';
if (!answerHtml) {
answerHtml = '<div style="white-space:pre-wrap;">' + this.escape(text || '') + '</div>';
}
$results.html('<div class="kb-answer panel panel-info"><div class="panel-body">' + html + '</div></div>');
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר'};
// Dedup sources by source_id (first occurrence = most relevant),
// but remember every page that came up per source so users can
// jump directly to the right page.
const perSource = {};
sources.forEach(s => {
if (!s.source_id) return;
const key = s.source_id;
if (!perSource[key]) {
perSource[key] = {
source_id: s.source_id,
title: s.title || '',
kind: s.kind || '',
identifier: s.identifier || '',
pages: [],
};
}
if (s.page_number && !perSource[key].pages.includes(s.page_number)) {
perSource[key].pages.push(s.page_number);
}
});
const sourceList = Object.values(perSource);
if (!sourceList.length) {
// No PDFs to show — render just the answer.
$results.html(
'<div class="kb-answer panel panel-info">' +
'<div class="panel-body" style="direction:rtl;text-align:right;">' +
answerHtml + '</div></div>'
);
return;
}
// Pick the initial PDF + page: the first source (highest-ranked)
// and its first-cited page.
const first = sourceList[0];
const firstPage = first.pages[0] || 1;
// Source picker: radio-like buttons that swap the iframe's src.
const pickerItems = sourceList.map((s, i) => {
const pagesLabel = s.pages.length
? ' · עמ׳ ' + s.pages.join(', ')
: '';
const k = kindHe[s.kind] || s.kind;
const active = i === 0 ? 'btn-primary' : 'btn-default';
return `<button type="button"
class="btn btn-xs ${active} kb-pdf-pick"
data-source-id="${s.source_id}"
data-page="${s.pages[0] || 1}"
style="margin:2px;">
${this.escape(k)}${this.escape(s.title)}${pagesLabel}
</button>`;
}).join(' ');
// Extra jump links per page, for the active source.
const pageLinks = first.pages.map(p =>
`<button type="button" class="btn btn-xs btn-default kb-page-jump"
data-source-id="${first.source_id}" data-page="${p}"
style="margin:2px;">עמ׳ ${p}</button>`
).join(' ');
const initialUrl = '?entryPoint=KnowledgeBasePdf&sourceId='
+ encodeURIComponent(first.source_id)
+ '#page=' + encodeURIComponent(firstPage);
$results.html(`
<div class="row" style="margin:0;">
<div class="col-md-6" style="padding-right:0;">
<div class="kb-answer panel panel-info">
<div class="panel-body" style="direction:rtl;text-align:right;max-height:80vh;overflow-y:auto;">
${answerHtml}
</div>
</div>
<div class="panel panel-default" style="direction:rtl;text-align:right;">
<div class="panel-heading"><strong>📎 מקורות</strong></div>
<div class="panel-body">
<div style="margin-bottom:6px;">${pickerItems}</div>
<div class="kb-page-links" style="font-size:0.9em;">
<span class="text-muted">עמודים רלוונטיים:</span> ${pageLinks}
</div>
</div>
</div>
</div>
<div class="col-md-6" style="padding-left:0;">
<iframe class="kb-pdf-iframe"
src="${initialUrl}"
style="width:100%;height:86vh;border:1px solid #ddd;background:#fafafa;"
title="מקור"></iframe>
</div>
</div>
`);
const self = this;
this.$el.find('.kb-pdf-pick').off('click').on('click', function () {
const id = $(this).data('source-id');
const page = $(this).data('page');
self.loadPdfInIframe(id, page);
self.$el.find('.kb-pdf-pick').removeClass('btn-primary').addClass('btn-default');
$(this).removeClass('btn-default').addClass('btn-primary');
// Refresh page links for the newly selected source.
const src = sourceList.find(x => x.source_id === id);
if (src && src.pages.length) {
const links = src.pages.map(p =>
`<button type="button" class="btn btn-xs btn-default kb-page-jump"
data-source-id="${src.source_id}" data-page="${p}"
style="margin:2px;">עמ׳ ${p}</button>`
).join(' ');
self.$el.find('.kb-page-links').html(
'<span class="text-muted">עמודים רלוונטיים:</span> ' + links
);
self.bindPageJumps();
}
});
this.bindPageJumps();
},
bindPageJumps: function () {
const self = this;
this.$el.find('.kb-page-jump').off('click').on('click', function () {
const id = $(this).data('source-id');
const page = $(this).data('page');
self.loadPdfInIframe(id, page);
});
},
loadPdfInIframe: function (sourceId, page) {
const url = '?entryPoint=KnowledgeBasePdf&sourceId='
+ encodeURIComponent(sourceId)
+ '#page=' + encodeURIComponent(page || 1);
this.$el.find('.kb-pdf-iframe').attr('src', url);
},
renderSourcesList: function () {
@@ -310,7 +512,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
setLoading: function (loading) {
const $btn = this.$el.find('[data-action="submit"]');
if (loading) {
$btn.prop('disabled', true).text(this.translate('Loading', 'labels'));
$btn.prop('disabled', true).text('טוען…');
// Only overwrite results with "מחפש…" for search. For ask,
// startAskProgress() renders a richer progress panel.
if (this.mode !== 'ask') {