From 8829a2e93a7f8a0cc0c7befa452a35b32bab08d7 Mon Sep 17 00:00:00 2001 From: Chaim Date: Fri, 24 Apr 2026 11:57:23 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20KnowledgeBase=20extension=20v0.1.6=20?= =?UTF-8?q?=E2=80=94=20inline=20PDF=20viewer=20in=20browse=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 2 + build.sh | 15 + .../knowledge-base/res/templates/kb/index.tpl | 59 ++++ .../knowledge-base/src/controllers/kb.js | 12 + .../src/views/dashlets/kb-search.js | 69 +++++ .../knowledge-base/src/views/kb/index.js | 289 ++++++++++++++++++ .../Controllers/KnowledgeBase.php | 94 ++++++ .../EntryPoints/KnowledgeBasePdf.php | 90 ++++++ .../Resources/i18n/en_US/Global.json | 8 + .../Resources/i18n/en_US/KnowledgeBase.json | 13 + .../Resources/i18n/fa_IR/Global.json | 8 + .../Resources/i18n/fa_IR/KnowledgeBase.json | 13 + .../Resources/i18n/he_IL/Global.json | 8 + .../Resources/i18n/he_IL/KnowledgeBase.json | 13 + .../Resources/metadata/app/dashlets.json | 6 + .../Resources/metadata/app/entryPoints.json | 5 + .../metadata/clientDefs/KnowledgeBase.json | 4 + .../metadata/scopes/KnowledgeBase.json | 4 + .../KnowledgeBase/Resources/routes.json | 34 +++ .../Services/KnowledgeBaseService.php | 251 +++++++++++++++ manifest.json | 14 + 21 files changed, 1011 insertions(+) create mode 100644 .gitignore create mode 100755 build.sh create mode 100644 files/client/custom/modules/knowledge-base/res/templates/kb/index.tpl create mode 100644 files/client/custom/modules/knowledge-base/src/controllers/kb.js create mode 100644 files/client/custom/modules/knowledge-base/src/views/dashlets/kb-search.js create mode 100644 files/client/custom/modules/knowledge-base/src/views/kb/index.js create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php create mode 100644 files/custom/Espo/Modules/KnowledgeBase/EntryPoints/KnowledgeBasePdf.php create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/en_US/Global.json create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/en_US/KnowledgeBase.json create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/fa_IR/Global.json create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/fa_IR/KnowledgeBase.json create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/he_IL/Global.json create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/he_IL/KnowledgeBase.json create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/app/dashlets.json create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/app/entryPoints.json create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/clientDefs/KnowledgeBase.json create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/scopes/KnowledgeBase.json create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json create mode 100644 files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php create mode 100644 manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c8b8eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.zip +.DS_Store diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..34defba --- /dev/null +++ b/build.sh @@ -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))" diff --git a/files/client/custom/modules/knowledge-base/res/templates/kb/index.tpl b/files/client/custom/modules/knowledge-base/res/templates/kb/index.tpl new file mode 100644 index 0000000..2c943e4 --- /dev/null +++ b/files/client/custom/modules/knowledge-base/res/templates/kb/index.tpl @@ -0,0 +1,59 @@ +
+ + + + + {{#ifEqual mode 'search'}} + + {{/ifEqual}} + + {{#ifEqual mode 'ask'}} + + {{/ifEqual}} + + {{#ifEqual mode 'browse'}} +
+
טוען מקורות…
+
+ {{/ifEqual}} + +
+
diff --git a/files/client/custom/modules/knowledge-base/src/controllers/kb.js b/files/client/custom/modules/knowledge-base/src/controllers/kb.js new file mode 100644 index 0000000..d651c31 --- /dev/null +++ b/files/client/custom/modules/knowledge-base/src/controllers/kb.js @@ -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', {}); + }, + + }); + +}); diff --git a/files/client/custom/modules/knowledge-base/src/views/dashlets/kb-search.js b/files/client/custom/modules/knowledge-base/src/views/dashlets/kb-search.js new file mode 100644 index 0000000..7d8f233 --- /dev/null +++ b/files/client/custom/modules/knowledge-base/src/views/dashlets/kb-search.js @@ -0,0 +1,69 @@ +define('modules/knowledge-base/views/dashlets/kb-search', ['views/dashlets/abstract/base'], function (Dep) { + + return Dep.extend({ + + name: 'KbSearch', + + templateContent: ` +
+
+ + +
+
+
+ פתח בבסיס הידע המלא → +
+
+ `, + + 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('
מחפש…
'); + Espo.Ajax.postRequest('KnowledgeBase/action/search', { + query: query, + kind: 'any', + topK: 5, + }).then(res => this.renderHits(res.hits || [])) + .catch(err => { + $r.html('
' + ((err && err.statusText) || 'Error') + '
'); + }); + }, + + renderHits: function (hits) { + const $r = this.$el.find('.kb-dashlet-results'); + if (!hits.length) { + $r.html('
לא נמצאו תוצאות.
'); + return; + } + const esc = s => String(s || '').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 `
+
[${kind}${ident}] ${esc(h.title || '')} + ${ref ? ' — ' + esc(ref) + '' : ''}
+
${preview}
+
`; + }).join(''); + $r.html(rows); + }, + + }); + +}); 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 new file mode 100644 index 0000000..1e87adc --- /dev/null +++ b/files/client/custom/modules/knowledge-base/src/views/kb/index.js @@ -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('
לא נמצאו תוצאות.
'); + 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
; that would double-break. + const content = this.escape(h.content || ''); + const srcLink = h.source_url + ? `מקור` + : ''; + 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 ` +
+
+ [${kind}${ident}] ${this.escape(h.title || '')} + ${path ? ' — ' + this.escape(path) + '' : ''} + ${pub} +
+
${content}
+ ${srcLink ? '' : ''} +
`; + }).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 = '
' + this.escape(text || '') + '
'; + } + $results.html('
' + html + '
'); + }, + + renderSourcesList: function () { + const $list = this.$el.find('.kb-sources'); + if (!$list.length) return; + if (!this._sources || !this._sources.length) { + $list.html('
אין מקורות.
'); + 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 `
  • + + ${this.escape(s.title)} + + ${ident}${pub} · ${s.chunk_count} קטעים +
  • `; + }).join(''); + return `

    ${kindHe[k]}

    `; + }).join(''); + $list.html(sections); + }, + + openSource: function (sourceId) { + const $results = this.$el.find('.kb-results'); + $results.html('
    טוען מסמך…
    '); + 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 = ` +
    +
    +

    ${this.escape(src.title || '')}

    + ${ident}${pub} +
    +
    `; + + // 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 = ` + +
    + מוצג ה-PDF המקורי. החיפוש בעמוד "חיפוש" ו-"שאל את שירה" עובד על ${chunksCount} קטעי הטקסט שחולצו מהמסמך הזה. +
    `; + $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 `
    + ${heading ? '
    ' + this.escape(heading) + '
    ' : ''} +
    ${this.escape(c.content)}
    +
    `; + }).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('
    מחפש…
    '); + } 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( + '
    ' + this.escape(msg.slice(0, 400)) + '
    ' + ); + }, + + escape: function (str) { + return String(str || '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + }, + + }); + +}); diff --git a/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php b/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php new file mode 100644 index 0000000..7b7849b --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php @@ -0,0 +1,94 @@ +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 ?? [] + ); + } +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/EntryPoints/KnowledgeBasePdf.php b/files/custom/Espo/Modules/KnowledgeBase/EntryPoints/KnowledgeBasePdf.php new file mode 100644 index 0000000..2a9ad7a --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/EntryPoints/KnowledgeBasePdf.php @@ -0,0 +1,90 @@ +`. + * + * 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); + } +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/en_US/Global.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/en_US/Global.json new file mode 100644 index 0000000..6c2a68a --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/en_US/Global.json @@ -0,0 +1,8 @@ +{ + "scopeNames": { + "KnowledgeBase": "Knowledge Base" + }, + "scopeNamesPlural": { + "KnowledgeBase": "Knowledge Base" + } +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/en_US/KnowledgeBase.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/en_US/KnowledgeBase.json new file mode 100644 index 0000000..1cbda10 --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/en_US/KnowledgeBase.json @@ -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" + } +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/fa_IR/Global.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/fa_IR/Global.json new file mode 100644 index 0000000..efc44d8 --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/fa_IR/Global.json @@ -0,0 +1,8 @@ +{ + "scopeNames": { + "KnowledgeBase": "בסיס ידע" + }, + "scopeNamesPlural": { + "KnowledgeBase": "בסיס ידע" + } +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/fa_IR/KnowledgeBase.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/fa_IR/KnowledgeBase.json new file mode 100644 index 0000000..e749dc9 --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/fa_IR/KnowledgeBase.json @@ -0,0 +1,13 @@ +{ + "labels": { + "Knowledge Base": "בסיס ידע", + "Search": "חיפוש", + "Ask Shira": "שאל את שירה", + "Browse": "עיון", + "Loading": "טוען…", + "No results": "לא נמצאו תוצאות" + }, + "dashlets": { + "KbSearch": "חיפוש בבסיס ידע" + } +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/he_IL/Global.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/he_IL/Global.json new file mode 100644 index 0000000..efc44d8 --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/he_IL/Global.json @@ -0,0 +1,8 @@ +{ + "scopeNames": { + "KnowledgeBase": "בסיס ידע" + }, + "scopeNamesPlural": { + "KnowledgeBase": "בסיס ידע" + } +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/he_IL/KnowledgeBase.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/he_IL/KnowledgeBase.json new file mode 100644 index 0000000..e749dc9 --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/i18n/he_IL/KnowledgeBase.json @@ -0,0 +1,13 @@ +{ + "labels": { + "Knowledge Base": "בסיס ידע", + "Search": "חיפוש", + "Ask Shira": "שאל את שירה", + "Browse": "עיון", + "Loading": "טוען…", + "No results": "לא נמצאו תוצאות" + }, + "dashlets": { + "KbSearch": "חיפוש בבסיס ידע" + } +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/app/dashlets.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/app/dashlets.json new file mode 100644 index 0000000..df8a75d --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/app/dashlets.json @@ -0,0 +1,6 @@ +{ + "KbSearch": { + "view": "modules/knowledge-base/views/dashlets/kb-search", + "aclScope": "KnowledgeBase" + } +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/app/entryPoints.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/app/entryPoints.json new file mode 100644 index 0000000..7ef6603 --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/app/entryPoints.json @@ -0,0 +1,5 @@ +{ + "KnowledgeBasePdf": { + "className": "Espo\\Modules\\KnowledgeBase\\EntryPoints\\KnowledgeBasePdf" + } +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/clientDefs/KnowledgeBase.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/clientDefs/KnowledgeBase.json new file mode 100644 index 0000000..a7fabaf --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/clientDefs/KnowledgeBase.json @@ -0,0 +1,4 @@ +{ + "controller": "modules/knowledge-base/controllers/kb", + "iconClass": "fas fa-book" +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/scopes/KnowledgeBase.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/scopes/KnowledgeBase.json new file mode 100644 index 0000000..b71aa91 --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/metadata/scopes/KnowledgeBase.json @@ -0,0 +1,4 @@ +{ + "tab": true, + "module": "KnowledgeBase" +} diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json new file mode 100644 index 0000000..4b624ce --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json @@ -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" + } + } +] diff --git a/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php b/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php new file mode 100644 index 0000000..4f99872 --- /dev/null +++ b/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php @@ -0,0 +1,251 @@ +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; + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6384e0a --- /dev/null +++ b/manifest.json @@ -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." +} \ No newline at end of file