feat: KnowledgeBase extension v0.1.6 — inline PDF viewer in browse tab
KnowledgeBase extension for EspoCRM: UI on top of the shira-hermes KB of
Israeli National Insurance law, regulations, and circulars.
Three modes:
- חיפוש — Hybrid (pgvector + tsvector) + Voyage rerank-2.5, returns ranked
chunks with heading_path and citations.
- שאל את שירה — Full agent loop; Shira picks up search_insurance_kb as
needed and returns a summary with citations.
- עיון — Browse all active sources. Click a source:
- PDF source (ספר הליקויים, ספר המבחנים, circulars): renders the
original PDF inline via an iframe proxied through the
KnowledgeBasePdf EntryPoint, so the layout/columns/tables are
preserved and Ctrl+F works natively.
- TXT source (חוק הביטוח הלאומי scraped from Wikisource): falls back
to the hierarchical chunk list with RTL styling.
Architecture:
- Controller: KnowledgeBase.php — thin proxy to shira-hermes /kb/*.
- Service: KnowledgeBaseService.php — shared curl plumbing; derives the
shira-hermes base URL from the SmartAssistant integration record so
there is no second admin config.
- EntryPoint: KnowledgeBasePdf.php — streams the PDF inline, wraps the
body in a php://temp stream for setBody, applies a locked-down CSP.
- JS: views/kb/index.js branches on source.original_path; modes wired
through the SmartAssistant fa_IR i18n convention.
Auth model:
- Browser → EspoCRM: session cookie / X-Api-Key (EspoCRM's existing auth).
- EspoCRM → shira-hermes: X-Api-Key from the SmartAssistant integration
(never exposed to the browser).
- Portal users are blocked at both the Controller and the EntryPoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
*.zip
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
|
||||||
|
MODULE=$(python3 -c "import json; m=json.load(open('manifest.json')); print(m.get('module', m['name']))")
|
||||||
|
ZIPNAME="${MODULE}-${VERSION}.zip"
|
||||||
|
|
||||||
|
echo "Building $ZIPNAME..."
|
||||||
|
rm -f "$ZIPNAME"
|
||||||
|
zip -r "$ZIPNAME" manifest.json files/ scripts/ \
|
||||||
|
-x "*.DS_Store" "*__MACOSX*" "*.zip" 2>/dev/null || \
|
||||||
|
zip -r "$ZIPNAME" manifest.json files/ \
|
||||||
|
-x "*.DS_Store" "*__MACOSX*" "*.zip"
|
||||||
|
|
||||||
|
echo "✓ Built: $ZIPNAME ($(du -h "$ZIPNAME" | cut -f1))"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<div class="kb-page" dir="rtl">
|
||||||
|
<div class="page-header">
|
||||||
|
<h3>בסיס ידע — ביטוח לאומי</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs" style="margin-bottom:12px;">
|
||||||
|
<li class="{{#ifEqual mode 'search'}}active{{/ifEqual}}">
|
||||||
|
<a href="#" role="button" data-action="switchMode" data-mode="search">חיפוש</a>
|
||||||
|
</li>
|
||||||
|
<li class="{{#ifEqual mode 'ask'}}active{{/ifEqual}}">
|
||||||
|
<a href="#" role="button" data-action="switchMode" data-mode="ask">שאל את שירה</a>
|
||||||
|
</li>
|
||||||
|
<li class="{{#ifEqual mode 'browse'}}active{{/ifEqual}}">
|
||||||
|
<a href="#" role="button" data-action="switchMode" data-mode="browse">עיון</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{{#ifEqual mode 'search'}}
|
||||||
|
<div class="kb-search-box" style="margin-bottom:12px;">
|
||||||
|
<div class="form-inline" style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
<input type="text" class="form-control" data-name="query"
|
||||||
|
placeholder="מונחי חיפוש…"
|
||||||
|
style="flex:1 1 320px;min-width:280px;" />
|
||||||
|
<select class="form-control" data-name="kind" style="flex:0 0 auto;">
|
||||||
|
<option value="any">כל הסוגים</option>
|
||||||
|
<option value="law">חוק</option>
|
||||||
|
<option value="regulation">תקנות</option>
|
||||||
|
<option value="circular">חוזרים</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-primary" data-action="submit">חפש</button>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted" style="margin-top:6px;">
|
||||||
|
חיפוש Hybrid: וקטורי + מילולי + rerank. מחזיר עד 8 קטעים רלוונטיים.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/ifEqual}}
|
||||||
|
|
||||||
|
{{#ifEqual mode 'ask'}}
|
||||||
|
<div class="kb-search-box" style="margin-bottom:12px;">
|
||||||
|
<div class="form-inline" style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
<input type="text" class="form-control" data-name="query"
|
||||||
|
placeholder="שאלה בשפה חופשית…"
|
||||||
|
style="flex:1 1 320px;min-width:280px;" />
|
||||||
|
<button type="button" class="btn btn-primary" data-action="submit">שאל את שירה</button>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted" style="margin-top:6px;">
|
||||||
|
שירה תחפש בבסיס הידע ותחזיר תשובה מסוכמת עם ציטוטים.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/ifEqual}}
|
||||||
|
|
||||||
|
{{#ifEqual mode 'browse'}}
|
||||||
|
<div class="kb-sources panel panel-default" style="padding:12px;">
|
||||||
|
<div class="text-muted">טוען מקורות…</div>
|
||||||
|
</div>
|
||||||
|
{{/ifEqual}}
|
||||||
|
|
||||||
|
<div class="kb-results" style="margin-top:12px;"></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
define('modules/knowledge-base/controllers/kb', ['controller'], function (Dep) {
|
||||||
|
|
||||||
|
return Dep.extend({
|
||||||
|
|
||||||
|
// Default action when the user clicks the "Knowledge Base" navbar tab.
|
||||||
|
actionIndex: function () {
|
||||||
|
this.main('modules/knowledge-base/views/kb/index', {});
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
define('modules/knowledge-base/views/dashlets/kb-search', ['views/dashlets/abstract/base'], function (Dep) {
|
||||||
|
|
||||||
|
return Dep.extend({
|
||||||
|
|
||||||
|
name: 'KbSearch',
|
||||||
|
|
||||||
|
templateContent: `
|
||||||
|
<div dir="rtl" style="padding:8px;">
|
||||||
|
<div style="display:flex;gap:6px;">
|
||||||
|
<input type="text" class="form-control input-sm" data-name="query"
|
||||||
|
placeholder="חפש בבסיס הידע…" style="flex:1;" />
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" data-action="submit">חפש</button>
|
||||||
|
</div>
|
||||||
|
<div class="kb-dashlet-results" style="margin-top:8px;max-height:300px;overflow-y:auto;"></div>
|
||||||
|
<div style="margin-top:6px;">
|
||||||
|
<a href="#KnowledgeBase" class="small">פתח בבסיס הידע המלא →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
|
||||||
|
events: {
|
||||||
|
'click [data-action="submit"]': function () {
|
||||||
|
this.runSearch();
|
||||||
|
},
|
||||||
|
'keypress input[data-name="query"]': function (e) {
|
||||||
|
if (e.which === 13) this.runSearch();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
runSearch: function () {
|
||||||
|
const query = (this.$el.find('input[data-name="query"]').val() || '').trim();
|
||||||
|
if (!query) return;
|
||||||
|
const $r = this.$el.find('.kb-dashlet-results');
|
||||||
|
$r.html('<div class="text-muted small">מחפש…</div>');
|
||||||
|
Espo.Ajax.postRequest('KnowledgeBase/action/search', {
|
||||||
|
query: query,
|
||||||
|
kind: 'any',
|
||||||
|
topK: 5,
|
||||||
|
}).then(res => this.renderHits(res.hits || []))
|
||||||
|
.catch(err => {
|
||||||
|
$r.html('<div class="alert alert-danger small">' + ((err && err.statusText) || 'Error') + '</div>');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHits: function (hits) {
|
||||||
|
const $r = this.$el.find('.kb-dashlet-results');
|
||||||
|
if (!hits.length) {
|
||||||
|
$r.html('<div class="text-muted small">לא נמצאו תוצאות.</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const esc = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר'};
|
||||||
|
const rows = hits.map(h => {
|
||||||
|
const kind = kindHe[h.kind] || h.kind;
|
||||||
|
const ident = h.identifier ? ' ' + esc(h.identifier) : '';
|
||||||
|
const ref = h.section_ref || h.heading_path || '';
|
||||||
|
const preview = esc((h.content || '').slice(0, 200)) + ((h.content || '').length > 200 ? '…' : '');
|
||||||
|
return `<div style="margin-bottom:8px;padding:6px;border-bottom:1px solid #eee;">
|
||||||
|
<div class="small"><strong>[${kind}${ident}]</strong> ${esc(h.title || '')}
|
||||||
|
${ref ? '<span class="text-muted"> — ' + esc(ref) + '</span>' : ''}</div>
|
||||||
|
<div class="small text-muted" style="white-space:pre-wrap;margin-top:3px;">${preview}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
$r.html(rows);
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||||
|
|
||||||
|
return Dep.extend({
|
||||||
|
|
||||||
|
template: 'knowledge-base:kb/index',
|
||||||
|
|
||||||
|
// Active mode: 'search' (default) | 'ask' | 'browse'
|
||||||
|
mode: 'search',
|
||||||
|
kind: 'any',
|
||||||
|
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
mode: this.mode,
|
||||||
|
kind: this.kind,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
events: {
|
||||||
|
'click [data-action="switchMode"]': function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = $(e.currentTarget).data('mode');
|
||||||
|
if (target === this.mode) return;
|
||||||
|
this.mode = target;
|
||||||
|
this.reRender();
|
||||||
|
},
|
||||||
|
'click [data-action="submit"]': function (e) {
|
||||||
|
if (e && e.preventDefault) e.preventDefault();
|
||||||
|
this.runQuery();
|
||||||
|
},
|
||||||
|
'keypress input[data-name="query"]': function (e) {
|
||||||
|
if (e.which === 13) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.runQuery();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'change select[data-name="kind"]': function (e) {
|
||||||
|
this.kind = $(e.currentTarget).val();
|
||||||
|
},
|
||||||
|
'click [data-action="viewSource"]': function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||||
|
if (id) this.openSource(id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
setup: function () {
|
||||||
|
// setTitle is only available on main.js; on a plain `view` we set
|
||||||
|
// document.title directly and let EspoCRM's navbar pick up the
|
||||||
|
// scopeName translation.
|
||||||
|
const label = this.translate('KnowledgeBase', 'scopeNames');
|
||||||
|
if (label && label !== 'KnowledgeBase') {
|
||||||
|
document.title = label;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
afterRender: function () {
|
||||||
|
// Load the source list up front so the browse tab is instant when
|
||||||
|
// the user clicks it.
|
||||||
|
if (!this._sourcesPromise) {
|
||||||
|
this._sourcesPromise = Espo.Ajax.getRequest('KnowledgeBase/action/sources')
|
||||||
|
.then(res => {
|
||||||
|
this._sources = res.sources || [];
|
||||||
|
this.renderSourcesList();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('KB: failed to load sources', err);
|
||||||
|
});
|
||||||
|
} else if (this._sources) {
|
||||||
|
this.renderSourcesList();
|
||||||
|
}
|
||||||
|
// 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');
|
||||||
|
},
|
||||||
|
|
||||||
|
runQuery: function () {
|
||||||
|
const query = (this.$el.find('input[data-name="query"]').val() || '').trim();
|
||||||
|
if (!query) return;
|
||||||
|
const kind = this.$el.find('select[data-name="kind"]').val() || 'any';
|
||||||
|
this.kind = kind;
|
||||||
|
|
||||||
|
if (this.mode === 'search') {
|
||||||
|
this.runSearch(query, kind);
|
||||||
|
} else if (this.mode === 'ask') {
|
||||||
|
this.runAsk(query);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
runSearch: function (query, kind) {
|
||||||
|
this.setLoading(true);
|
||||||
|
Espo.Ajax.postRequest('KnowledgeBase/action/search', {
|
||||||
|
query: query,
|
||||||
|
kind: kind,
|
||||||
|
topK: 8,
|
||||||
|
}).then(res => {
|
||||||
|
this.setLoading(false);
|
||||||
|
this.renderSearchResults(res.hits || []);
|
||||||
|
}).catch(err => {
|
||||||
|
this.setLoading(false);
|
||||||
|
this.showError(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
runAsk: function (message) {
|
||||||
|
this.setLoading(true);
|
||||||
|
Espo.Ajax.postRequest('KnowledgeBase/action/ask', {
|
||||||
|
message: message,
|
||||||
|
}).then(res => {
|
||||||
|
this.setLoading(false);
|
||||||
|
this.renderAskAnswer(res.text || '');
|
||||||
|
}).catch(err => {
|
||||||
|
this.setLoading(false);
|
||||||
|
this.showError(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderSearchResults: function (hits) {
|
||||||
|
const $results = this.$el.find('.kb-results');
|
||||||
|
if (!hits.length) {
|
||||||
|
$results.html('<div class="text-muted">לא נמצאו תוצאות.</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר'};
|
||||||
|
const rows = hits.map(h => {
|
||||||
|
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;';
|
||||||
|
return `
|
||||||
|
<div class="kb-hit panel panel-default" style="margin-bottom:10px;">
|
||||||
|
<div class="panel-heading" style="direction:rtl;text-align:right;">
|
||||||
|
<strong>[${kind}${ident}]</strong> ${this.escape(h.title || '')}
|
||||||
|
${path ? ' — <span class="text-muted">' + this.escape(path) + '</span>' : ''}
|
||||||
|
<span class="text-muted small">${pub}</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body" style="${bodyStyle}">${content}</div>
|
||||||
|
${srcLink ? '<div class="panel-footer small">' + srcLink + '</div>' : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
$results.html(rows);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderAskAnswer: function (text) {
|
||||||
|
const $results = this.$el.find('.kb-results');
|
||||||
|
// Shira replies in markdown; render through EspoCRM's markdown
|
||||||
|
// helper when available, otherwise fall back to escaped text.
|
||||||
|
let html;
|
||||||
|
try {
|
||||||
|
const helper = this.getHelper && this.getHelper();
|
||||||
|
if (helper && typeof helper.transformMarkdownText === 'function') {
|
||||||
|
html = helper.transformMarkdownText(text || '');
|
||||||
|
if (html && html.toString) html = html.toString();
|
||||||
|
}
|
||||||
|
} catch (e) { /* fall through */ }
|
||||||
|
if (!html) {
|
||||||
|
html = '<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>');
|
||||||
|
},
|
||||||
|
|
||||||
|
renderSourcesList: function () {
|
||||||
|
const $list = this.$el.find('.kb-sources');
|
||||||
|
if (!$list.length) return;
|
||||||
|
if (!this._sources || !this._sources.length) {
|
||||||
|
$list.html('<div class="text-muted">אין מקורות.</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזרים'};
|
||||||
|
const grouped = {law: [], regulation: [], circular: []};
|
||||||
|
this._sources.forEach(s => {
|
||||||
|
if (grouped[s.kind]) grouped[s.kind].push(s);
|
||||||
|
});
|
||||||
|
const sections = Object.keys(grouped).filter(k => grouped[k].length).map(k => {
|
||||||
|
const items = grouped[k].map(s => {
|
||||||
|
const ident = s.identifier ? ' · ' + this.escape(s.identifier) : '';
|
||||||
|
const pub = s.published_at ? ' · ' + s.published_at : '';
|
||||||
|
return `<li>
|
||||||
|
<a href="#" role="button" data-action="viewSource" data-id="${s.id}">
|
||||||
|
${this.escape(s.title)}
|
||||||
|
</a>
|
||||||
|
<span class="text-muted small">${ident}${pub} · ${s.chunk_count} קטעים</span>
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
return `<h4>${kindHe[k]}</h4><ul>${items}</ul>`;
|
||||||
|
}).join('');
|
||||||
|
$list.html(sections);
|
||||||
|
},
|
||||||
|
|
||||||
|
openSource: function (sourceId) {
|
||||||
|
const $results = this.$el.find('.kb-results');
|
||||||
|
$results.html('<div class="text-muted">טוען מסמך…</div>');
|
||||||
|
Espo.Ajax.getRequest('KnowledgeBase/action/chunks', {sourceId: sourceId})
|
||||||
|
.then(res => {
|
||||||
|
const src = res.source || {};
|
||||||
|
const chunks = res.chunks || [];
|
||||||
|
const ident = src.identifier ? ' · ' + this.escape(src.identifier) : '';
|
||||||
|
const pub = src.published_at ? ' · פורסם ' + src.published_at : '';
|
||||||
|
const head = `
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 style="margin:0;">${this.escape(src.title || '')}</h3>
|
||||||
|
<small class="text-muted">${ident}${pub}</small>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// If the source is a PDF (original_path ends in .pdf),
|
||||||
|
// render it inline via the EntryPoint proxy. Ctrl+F works
|
||||||
|
// inside the iframe via the browser's native PDF viewer,
|
||||||
|
// which also gives zoom, print, and download controls.
|
||||||
|
const originalPath = (src.original_path || '').toLowerCase();
|
||||||
|
if (originalPath.endsWith('.pdf')) {
|
||||||
|
const pdfUrl = '?entryPoint=KnowledgeBasePdf&sourceId=' + encodeURIComponent(sourceId);
|
||||||
|
const chunksCount = chunks.length;
|
||||||
|
const iframe = `
|
||||||
|
<iframe
|
||||||
|
src="${pdfUrl}"
|
||||||
|
style="width:100%;height:80vh;border:1px solid #ddd;background:#fafafa;"
|
||||||
|
title="${this.escape(src.title || '')}"></iframe>
|
||||||
|
<div class="small text-muted" style="margin-top:6px;direction:rtl;text-align:right;">
|
||||||
|
מוצג ה-PDF המקורי. החיפוש בעמוד "חיפוש" ו-"שאל את שירה" עובד על ${chunksCount} קטעי הטקסט שחולצו מהמסמך הזה.
|
||||||
|
</div>`;
|
||||||
|
$results.html(head + iframe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hebrew content with mixed LTR (numbers, Latin, ־) renders
|
||||||
|
// correctly only when we force RTL direction and tell the
|
||||||
|
// bidi algorithm to isolate each paragraph. Plain white-space:
|
||||||
|
// pre-wrap alone preserves line breaks but leaves the bidi
|
||||||
|
// resolution up to the browser, which produces jumbled
|
||||||
|
// glyphs on PDF-extracted Hebrew + Latin runs.
|
||||||
|
const contentStyle =
|
||||||
|
'direction:rtl;' +
|
||||||
|
'unicode-bidi:plaintext;' +
|
||||||
|
'white-space:pre-wrap;' +
|
||||||
|
'font-family:"Segoe UI",Arial,sans-serif;' +
|
||||||
|
'line-height:1.7;' +
|
||||||
|
'text-align:right;';
|
||||||
|
const body = chunks.map(c => {
|
||||||
|
const heading = c.heading_path || c.section_ref || '';
|
||||||
|
return `<div class="kb-chunk" style="margin-bottom:14px;padding:10px;border:1px solid #eee;border-radius:4px;">
|
||||||
|
${heading ? '<div class="text-muted" style="font-size:0.85em;margin-bottom:6px;direction:rtl;text-align:right;">' + this.escape(heading) + '</div>' : ''}
|
||||||
|
<div style="${contentStyle}">${this.escape(c.content)}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
$results.html(head + body);
|
||||||
|
})
|
||||||
|
.catch(err => this.showError(err));
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading: function (loading) {
|
||||||
|
const $btn = this.$el.find('[data-action="submit"]');
|
||||||
|
if (loading) {
|
||||||
|
$btn.prop('disabled', true).text(this.translate('Loading', 'labels'));
|
||||||
|
this.$el.find('.kb-results').html('<div class="text-muted">מחפש…</div>');
|
||||||
|
} else {
|
||||||
|
$btn.prop('disabled', false).text(this.getSubmitLabel());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSubmitLabel: function () {
|
||||||
|
if (this.mode === 'ask') return 'שאל את שירה';
|
||||||
|
return 'חפש';
|
||||||
|
},
|
||||||
|
|
||||||
|
showError: function (err) {
|
||||||
|
const msg = (err && err.responseText) || (err && err.message) || 'Unknown error';
|
||||||
|
this.$el.find('.kb-results').html(
|
||||||
|
'<div class="alert alert-danger">' + this.escape(msg.slice(0, 400)) + '</div>'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
escape: function (str) {
|
||||||
|
return String(str || '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Espo\Modules\KnowledgeBase\Controllers;
|
||||||
|
|
||||||
|
use Espo\Core\Api\Request;
|
||||||
|
use Espo\Core\Api\Response;
|
||||||
|
use Espo\Core\Exceptions\BadRequest;
|
||||||
|
use Espo\Core\Exceptions\Forbidden;
|
||||||
|
use Espo\Core\InjectableFactory;
|
||||||
|
use Espo\Core\Acl;
|
||||||
|
use Espo\Modules\KnowledgeBase\Services\KnowledgeBaseService;
|
||||||
|
use Espo\Entities\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin proxy controller. The EspoCRM UI talks to these actions; each
|
||||||
|
* action forwards to shira-hermes (/kb/*) with the shared API key held
|
||||||
|
* server-side. The browser never sees the shira-hermes key.
|
||||||
|
*/
|
||||||
|
class KnowledgeBase
|
||||||
|
{
|
||||||
|
private InjectableFactory $injectableFactory;
|
||||||
|
private Acl $acl;
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user)
|
||||||
|
{
|
||||||
|
$this->injectableFactory = $injectableFactory;
|
||||||
|
$this->acl = $acl;
|
||||||
|
$this->user = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getService(): KnowledgeBaseService
|
||||||
|
{
|
||||||
|
return $this->injectableFactory->create(KnowledgeBaseService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkAccess(): void
|
||||||
|
{
|
||||||
|
// Anyone with a portal-less account (regular users) can read the KB.
|
||||||
|
if ($this->user->isPortal()) {
|
||||||
|
throw new Forbidden('Portal users have no KB access.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionSearch(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
if (empty($data->query)) {
|
||||||
|
throw new BadRequest('query is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getService()->search(
|
||||||
|
trim($data->query),
|
||||||
|
$data->kind ?? 'any',
|
||||||
|
(int) ($data->top_k ?? $data->topK ?? 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActionSources(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
return $this->getService()->listSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActionChunks(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$sourceId = $request->getQueryParam('sourceId');
|
||||||
|
|
||||||
|
if (!$sourceId || !is_numeric($sourceId)) {
|
||||||
|
throw new BadRequest('sourceId (numeric) is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getService()->sourceChunks((int) $sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionAsk(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
if (empty($data->message)) {
|
||||||
|
throw new BadRequest('message is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getService()->ask(
|
||||||
|
trim($data->message),
|
||||||
|
$data->conversationId ?? null,
|
||||||
|
$data->conversationHistory ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Espo\Modules\KnowledgeBase\EntryPoints;
|
||||||
|
|
||||||
|
use Espo\Core\Api\Request;
|
||||||
|
use Espo\Core\Api\Response;
|
||||||
|
use Espo\Core\EntryPoint\EntryPoint;
|
||||||
|
use Espo\Core\Exceptions\BadRequest;
|
||||||
|
use Espo\Core\Exceptions\Error;
|
||||||
|
use Espo\Core\Exceptions\Forbidden;
|
||||||
|
use Espo\Core\Exceptions\NotFound;
|
||||||
|
use Espo\Core\InjectableFactory;
|
||||||
|
use Espo\Entities\User;
|
||||||
|
use Espo\Modules\KnowledgeBase\Services\KnowledgeBaseService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams the original PDF of a KB source to an iframe in the browse tab.
|
||||||
|
* Access: `?entryPoint=KnowledgeBasePdf&sourceId=<id>`.
|
||||||
|
*
|
||||||
|
* We intentionally do NOT reuse EspoCRM's Attachment ACL model — KB sources
|
||||||
|
* are not Entities. The gate is the same rule as the Controller: no portal
|
||||||
|
* users. Anyone with a regular user account in the CRM can read the KB.
|
||||||
|
*/
|
||||||
|
class KnowledgeBasePdf implements EntryPoint
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private InjectableFactory $injectableFactory,
|
||||||
|
private User $user,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
if ($this->user->isPortal()) {
|
||||||
|
throw new Forbidden('Portal users have no KB access.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceId = $request->getQueryParam('sourceId');
|
||||||
|
if (!$sourceId || !is_numeric($sourceId)) {
|
||||||
|
throw new BadRequest('sourceId (numeric) is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = $this->injectableFactory->create(KnowledgeBaseService::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdf = $service->getPdfBinary((int) $sourceId);
|
||||||
|
} catch (NotFound | Forbidden | BadRequest $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
throw new Error('Failed to fetch PDF: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = (string) ($pdf['body'] ?? '');
|
||||||
|
$contentType = (string) ($pdf['contentType'] ?? 'application/pdf');
|
||||||
|
$filename = (string) ($pdf['filename'] ?? ('source-' . $sourceId . '.pdf'));
|
||||||
|
|
||||||
|
if ($body === '') {
|
||||||
|
throw new Error('Empty PDF body');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disposition: inline so the browser renders rather than downloads.
|
||||||
|
// Quote-escape the ASCII fallback; add RFC 5987 encoded UTF-8 for the
|
||||||
|
// real Hebrew filename.
|
||||||
|
$asciiFallback = 'source-' . (int) $sourceId . '.pdf';
|
||||||
|
$utf8Encoded = rawurlencode($filename);
|
||||||
|
$disposition = sprintf(
|
||||||
|
'inline; filename="%s"; filename*=UTF-8\'\'%s',
|
||||||
|
$asciiFallback,
|
||||||
|
$utf8Encoded,
|
||||||
|
);
|
||||||
|
|
||||||
|
// setBody() expects a PSR StreamInterface, not a raw string. Wrap
|
||||||
|
// the binary in an in-memory php://temp stream so the ResponseWrapper
|
||||||
|
// can stream it to the client without buffering twice.
|
||||||
|
$stream = fopen('php://temp', 'w+b');
|
||||||
|
fwrite($stream, $body);
|
||||||
|
rewind($stream);
|
||||||
|
$psrStream = \GuzzleHttp\Psr7\Utils::streamFor($stream);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->setHeader('Content-Type', $contentType)
|
||||||
|
->setHeader('Content-Disposition', $disposition)
|
||||||
|
->setHeader('Content-Length', (string) strlen($body))
|
||||||
|
->setHeader('X-Content-Type-Options', 'nosniff')
|
||||||
|
->setHeader('Cache-Control', 'private, max-age=300')
|
||||||
|
// Downloading via the attachment entry point uses a locked-down
|
||||||
|
// CSP; we do the same here to keep inline viewing safe.
|
||||||
|
->setHeader('Content-Security-Policy', "default-src 'self'")
|
||||||
|
->setBody($psrStream);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"scopeNames": {
|
||||||
|
"KnowledgeBase": "Knowledge Base"
|
||||||
|
},
|
||||||
|
"scopeNamesPlural": {
|
||||||
|
"KnowledgeBase": "Knowledge Base"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Knowledge Base": "Knowledge Base",
|
||||||
|
"Search": "Search",
|
||||||
|
"Ask Shira": "Ask Shira",
|
||||||
|
"Browse": "Browse",
|
||||||
|
"Loading": "Loading…",
|
||||||
|
"No results": "No results"
|
||||||
|
},
|
||||||
|
"dashlets": {
|
||||||
|
"KbSearch": "KB Search"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"scopeNames": {
|
||||||
|
"KnowledgeBase": "בסיס ידע"
|
||||||
|
},
|
||||||
|
"scopeNamesPlural": {
|
||||||
|
"KnowledgeBase": "בסיס ידע"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Knowledge Base": "בסיס ידע",
|
||||||
|
"Search": "חיפוש",
|
||||||
|
"Ask Shira": "שאל את שירה",
|
||||||
|
"Browse": "עיון",
|
||||||
|
"Loading": "טוען…",
|
||||||
|
"No results": "לא נמצאו תוצאות"
|
||||||
|
},
|
||||||
|
"dashlets": {
|
||||||
|
"KbSearch": "חיפוש בבסיס ידע"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"scopeNames": {
|
||||||
|
"KnowledgeBase": "בסיס ידע"
|
||||||
|
},
|
||||||
|
"scopeNamesPlural": {
|
||||||
|
"KnowledgeBase": "בסיס ידע"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Knowledge Base": "בסיס ידע",
|
||||||
|
"Search": "חיפוש",
|
||||||
|
"Ask Shira": "שאל את שירה",
|
||||||
|
"Browse": "עיון",
|
||||||
|
"Loading": "טוען…",
|
||||||
|
"No results": "לא נמצאו תוצאות"
|
||||||
|
},
|
||||||
|
"dashlets": {
|
||||||
|
"KbSearch": "חיפוש בבסיס ידע"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"KbSearch": {
|
||||||
|
"view": "modules/knowledge-base/views/dashlets/kb-search",
|
||||||
|
"aclScope": "KnowledgeBase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"KnowledgeBasePdf": {
|
||||||
|
"className": "Espo\\Modules\\KnowledgeBase\\EntryPoints\\KnowledgeBasePdf"
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"controller": "modules/knowledge-base/controllers/kb",
|
||||||
|
"iconClass": "fas fa-book"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tab": true,
|
||||||
|
"module": "KnowledgeBase"
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"route": "/KnowledgeBase/action/search",
|
||||||
|
"method": "post",
|
||||||
|
"params": {
|
||||||
|
"controller": "KnowledgeBase",
|
||||||
|
"action": "search"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/KnowledgeBase/action/sources",
|
||||||
|
"method": "get",
|
||||||
|
"params": {
|
||||||
|
"controller": "KnowledgeBase",
|
||||||
|
"action": "sources"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/KnowledgeBase/action/chunks",
|
||||||
|
"method": "get",
|
||||||
|
"params": {
|
||||||
|
"controller": "KnowledgeBase",
|
||||||
|
"action": "chunks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/KnowledgeBase/action/ask",
|
||||||
|
"method": "post",
|
||||||
|
"params": {
|
||||||
|
"controller": "KnowledgeBase",
|
||||||
|
"action": "ask"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Espo\Modules\KnowledgeBase\Services;
|
||||||
|
|
||||||
|
use Espo\Core\Exceptions\BadRequest;
|
||||||
|
use Espo\Core\Exceptions\Error;
|
||||||
|
use Espo\Core\Exceptions\NotFound;
|
||||||
|
use Espo\Core\Utils\Config;
|
||||||
|
use Espo\Core\Utils\Log;
|
||||||
|
use Espo\ORM\EntityManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin HTTP client over shira-hermes `/kb/*` endpoints. Reuses the
|
||||||
|
* SmartAssistant integration record for connection details: the same
|
||||||
|
* shira-hermes instance hosts both the chat webhook and the KB API, so
|
||||||
|
* deriving the KB base URL from SmartAssistant's webhookUrl keeps
|
||||||
|
* everything in one place and avoids a second admin form.
|
||||||
|
*/
|
||||||
|
class KnowledgeBaseService
|
||||||
|
{
|
||||||
|
private EntityManager $entityManager;
|
||||||
|
private Config $config;
|
||||||
|
private Log $log;
|
||||||
|
|
||||||
|
public function __construct(EntityManager $entityManager, Config $config, Log $log)
|
||||||
|
{
|
||||||
|
$this->entityManager = $entityManager;
|
||||||
|
$this->config = $config;
|
||||||
|
$this->log = $log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(string $query, string $kind, int $topK): array
|
||||||
|
{
|
||||||
|
$topK = max(1, min(15, $topK));
|
||||||
|
if (!in_array($kind, ['any', 'law', 'regulation', 'circular'], true)) {
|
||||||
|
$kind = 'any';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->post('/kb/search', [
|
||||||
|
'query' => $query,
|
||||||
|
'kind' => $kind,
|
||||||
|
'top_k' => $topK,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listSources(): array
|
||||||
|
{
|
||||||
|
return $this->get('/kb/sources');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceChunks(int $sourceId): array
|
||||||
|
{
|
||||||
|
return $this->get('/kb/source/' . $sourceId . '/chunks');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ask(string $message, ?string $conversationId, array $history): array
|
||||||
|
{
|
||||||
|
return $this->post('/kb/ask', [
|
||||||
|
'message' => $message,
|
||||||
|
'conversation_id' => $conversationId,
|
||||||
|
'conversation_history' => $history,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the original PDF bytes of a source from shira-hermes.
|
||||||
|
*
|
||||||
|
* @return array{body:string, contentType:string, filename:string}
|
||||||
|
* @throws NotFound when the source doesn't exist in the KB
|
||||||
|
* @throws BadRequest when the source has no PDF (stored as plain text)
|
||||||
|
* @throws Error on transport or upstream 5xx errors
|
||||||
|
*/
|
||||||
|
public function getPdfBinary(int $sourceId): array
|
||||||
|
{
|
||||||
|
$resp = $this->requestBinary('GET', '/kb/source/' . $sourceId . '/pdf');
|
||||||
|
|
||||||
|
$status = $resp['status'];
|
||||||
|
if ($status === 404) {
|
||||||
|
throw new NotFound('KB source not found.');
|
||||||
|
}
|
||||||
|
if ($status === 409) {
|
||||||
|
throw new BadRequest('This source has no PDF original.');
|
||||||
|
}
|
||||||
|
if ($status < 200 || $status >= 300) {
|
||||||
|
$this->log->error("KnowledgeBase: PDF HTTP {$status}");
|
||||||
|
throw new Error("Knowledge Base returned HTTP {$status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the UTF-8 filename from Content-Disposition if present, fall
|
||||||
|
// back to a generic name. The EntryPoint will re-emit the same shape.
|
||||||
|
$filename = 'source-' . $sourceId . '.pdf';
|
||||||
|
if (preg_match("/filename\\*=UTF-8''([^;\\s]+)/i", $resp['disposition'] ?? '', $m)) {
|
||||||
|
$filename = rawurldecode($m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'body' => $resp['body'],
|
||||||
|
'contentType' => $resp['contentType'] ?: 'application/pdf',
|
||||||
|
'filename' => $filename,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTTP helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
private function getBaseUrl(): string
|
||||||
|
{
|
||||||
|
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||||
|
if (!$integration || !$integration->get('enabled')) {
|
||||||
|
throw new Error('SmartAssistant integration is not enabled; cannot reach shira-hermes KB.');
|
||||||
|
}
|
||||||
|
$data = $integration->get('data') ?? (object) [];
|
||||||
|
$webhookUrl = $data->webhookUrl ?? null;
|
||||||
|
if (!$webhookUrl) {
|
||||||
|
throw new Error('SmartAssistant webhook URL is not configured.');
|
||||||
|
}
|
||||||
|
// The webhook URL typically looks like: https://shira.dev.marcus-law.co.il/api/smart-assistant/chat
|
||||||
|
// We want the origin only — strip any path and trailing slash.
|
||||||
|
$parts = parse_url($webhookUrl);
|
||||||
|
if (!$parts || empty($parts['scheme']) || empty($parts['host'])) {
|
||||||
|
throw new Error('Webhook URL is not parseable.');
|
||||||
|
}
|
||||||
|
$origin = $parts['scheme'] . '://' . $parts['host'];
|
||||||
|
if (!empty($parts['port'])) {
|
||||||
|
$origin .= ':' . $parts['port'];
|
||||||
|
}
|
||||||
|
return $origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getApiKey(): ?string
|
||||||
|
{
|
||||||
|
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||||
|
if (!$integration || !$integration->get('enabled')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = $integration->get('data') ?? (object) [];
|
||||||
|
return $data->apiKey ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get(string $path): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', $path, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function post(string $path, array $payload): array
|
||||||
|
{
|
||||||
|
return $this->request('POST', $path, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of request() for binary responses (PDFs). No json_decode; splits
|
||||||
|
* the response into headers + body so callers can see Content-Type and
|
||||||
|
* Content-Disposition.
|
||||||
|
*
|
||||||
|
* @return array{body:string, contentType:string, disposition:string, status:int}
|
||||||
|
*/
|
||||||
|
private function requestBinary(string $method, string $path): array
|
||||||
|
{
|
||||||
|
$url = $this->getBaseUrl() . $path;
|
||||||
|
$apiKey = $this->getApiKey();
|
||||||
|
|
||||||
|
$headers = ['Accept: application/pdf,*/*'];
|
||||||
|
if ($apiKey) {
|
||||||
|
$headers[] = 'X-Api-Key: ' . $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_TIMEOUT => 180,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
$this->log->error("KnowledgeBase: HTTP error calling {$url}: {$error}");
|
||||||
|
throw new Error("Failed to reach Knowledge Base: {$error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawHeaders = substr($raw, 0, $headerSize) ?: '';
|
||||||
|
$body = substr($raw, $headerSize) ?: '';
|
||||||
|
|
||||||
|
$contentType = '';
|
||||||
|
$disposition = '';
|
||||||
|
foreach (explode("\r\n", $rawHeaders) as $line) {
|
||||||
|
if (stripos($line, 'Content-Type:') === 0) {
|
||||||
|
$contentType = trim(substr($line, strlen('Content-Type:')));
|
||||||
|
} elseif (stripos($line, 'Content-Disposition:') === 0) {
|
||||||
|
$disposition = trim(substr($line, strlen('Content-Disposition:')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'body' => $body,
|
||||||
|
'contentType' => $contentType,
|
||||||
|
'disposition' => $disposition,
|
||||||
|
'status' => $httpCode,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request(string $method, string $path, ?array $payload): array
|
||||||
|
{
|
||||||
|
$url = $this->getBaseUrl() . $path;
|
||||||
|
$apiKey = $this->getApiKey();
|
||||||
|
|
||||||
|
$headers = ['Accept: application/json'];
|
||||||
|
if ($apiKey) {
|
||||||
|
$headers[] = 'X-Api-Key: ' . $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$opts = [
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 180,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 10,
|
||||||
|
];
|
||||||
|
if ($payload !== null) {
|
||||||
|
$headers[] = 'Content-Type: application/json';
|
||||||
|
$opts[CURLOPT_POSTFIELDS] = json_encode($payload);
|
||||||
|
}
|
||||||
|
$opts[CURLOPT_HTTPHEADER] = $headers;
|
||||||
|
curl_setopt_array($ch, $opts);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
$this->log->error("KnowledgeBase: HTTP error calling {$url}: {$error}");
|
||||||
|
throw new Error("Failed to reach Knowledge Base: {$error}");
|
||||||
|
}
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300) {
|
||||||
|
$this->log->error("KnowledgeBase: HTTP {$httpCode} from {$url}: {$body}");
|
||||||
|
throw new Error("Knowledge Base returned HTTP {$httpCode}");
|
||||||
|
}
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new Error('Invalid JSON from Knowledge Base.');
|
||||||
|
}
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "KnowledgeBase",
|
||||||
|
"module": "KnowledgeBase",
|
||||||
|
"version": "0.1.6",
|
||||||
|
"acceptableVersions": [
|
||||||
|
">=8.0.0"
|
||||||
|
],
|
||||||
|
"php": [
|
||||||
|
">=8.1"
|
||||||
|
],
|
||||||
|
"releaseDate": "2026-04-24",
|
||||||
|
"author": "klear",
|
||||||
|
"description": "Knowledge Base — Israeli National Insurance law, regulations, and circulars. Hybrid search + ask-shira, powered by shira-hermes KB."
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user