feat(kb): topic picker + topic-scoped client (Phase 1, no manifest bump)

Wires the EspoCRM extension to the multi-topic backend introduced in
shira-hermes commit f03721e. The KB page header now shows a topic
<select> ('נושא:'); the user's selection persists in localStorage
('kb-topic') and is sent with every search/ask/sources call so cross-
domain bleed-over is impossible. Single seeded topic ('ביטוח לאומי')
means existing users see no functional change until a second topic is
added in Phase 4 (Task Master #15).

- Resources/routes.json: GET /KnowledgeBase/action/topics.
- Controllers/KnowledgeBase.php: actionTopics + topicId passthrough on
  search/sources/ask. Numeric coercion for the request payload.
- Services/KnowledgeBaseService.php: listTopics(); search/listSources/
  ask take an optional topicId and forward it as topic_id to upstream.
- EntryPoints/KnowledgeBaseAskStream.php: ?topicId=N becomes topic_id
  in the JSON body sent to /kb/ask/stream.
- res/templates/kb/index.tpl: topic <select> in the page header,
  with a kb-topic-name-suffix span that JS fills with the active name.
- src/views/kb/index.js: module-level _topics cache + LS_TOPIC. setup
  reads localStorage; afterRender gates the picker fill + sources fetch
  on topics being loaded so a stale id from a deleted topic doesn't
  fire a 400-ing /sources call. switchTopic clears _activeAsk/Search +
  _lastAsk/Search + sessionStorage entries + this._sources, then
  reRenders. Cross-topic guard in afterRender drops cached state whose
  topicId !== this.topicId. _activeAsk / _lastAsk / _activeSearch /
  _lastSearch all carry topicId. EventSource URL gains &topicId.

Refs Task Master #12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 14:15:36 +00:00
parent 6f11895dd7
commit ba75463661
6 changed files with 264 additions and 34 deletions
@@ -1,6 +1,13 @@
<div class="kb-page" dir="rtl"> <div class="kb-page" dir="rtl">
<div class="page-header"> <div class="page-header" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;">
<h3>בסיס ידע — ביטוח לאומי</h3> <h3 style="margin:0;">בסיס ידע<span class="kb-topic-name-suffix"></span></h3>
<div class="kb-topic-picker" style="display:flex;align-items:center;gap:8px;">
<label for="kb-topic-select" class="text-muted" style="margin:0;font-weight:normal;">נושא:</label>
<select id="kb-topic-select" class="form-control kb-topic-select" data-name="topic"
style="min-width:180px;">
<option value="">טוען…</option>
</select>
</div>
</div> </div>
<ul class="nav nav-tabs" style="margin-bottom:12px;"> <ul class="nav nav-tabs" style="margin-bottom:12px;">
@@ -6,14 +6,17 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
// it. sessionStorage backs the last completed answer so a hard reload // it. sessionStorage backs the last completed answer so a hard reload
// also recovers it. None of this leaks to the next browser session — // also recovers it. None of this leaks to the next browser session —
// ask history is intentionally ephemeral. // ask history is intentionally ephemeral.
let _activeAsk = null; // {question, promise, startedAt} while in flight let _activeAsk = null; // {question, topicId, promise, startedAt} while in flight
let _lastAsk = null; // {question, text, sources, completedAt} after success let _lastAsk = null; // {question, topicId, text, sources, completedAt} after success
let _activeSearch = null; // {query, kind, promise} while in flight let _activeSearch = null; // {query, kind, topicId, promise} while in flight
let _lastSearch = null; // {query, kind, hits, selectedIdx, completedAt} let _lastSearch = null; // {query, kind, topicId, hits, selectedIdx, completedAt}
let _topics = null; // [{id, slug, name, description, is_active}]
let _topicsPromise = null; // dedupe concurrent fetches across mounts
const SS_ASK = 'kb-last-ask'; const SS_ASK = 'kb-last-ask';
const SS_SEARCH = 'kb-last-search'; const SS_SEARCH = 'kb-last-search';
const LS_SPLIT = 'kb-split-pct'; // long-lived UI preference (not session) const LS_SPLIT = 'kb-split-pct'; // long-lived UI preference (not session)
const LS_TOPIC = 'kb-topic'; // selected topic id, persists across sessions
// Cached results auto-expire after this long. The user explicitly asked // Cached results auto-expire after this long. The user explicitly asked
// for a clear button, but auto-clearing also handles the case where they // for a clear button, but auto-clearing also handles the case where they
// come back hours later and the prior result is no longer relevant. // come back hours later and the prior result is no longer relevant.
@@ -46,6 +49,21 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
_lastSearch = _loadJson(sessionStorage, SS_SEARCH, x => x && typeof x.query === 'string' && Array.isArray(x.hits)); _lastSearch = _loadJson(sessionStorage, SS_SEARCH, x => x && typeof x.query === 'string' && Array.isArray(x.hits));
} }
function _readPersistedTopicId() {
try {
const raw = localStorage.getItem(LS_TOPIC);
if (!raw) return null;
const v = parseInt(raw, 10);
return Number.isFinite(v) ? v : null;
} catch (e) { return null; }
}
function _writePersistedTopicId(id) {
try {
if (id == null) localStorage.removeItem(LS_TOPIC);
else localStorage.setItem(LS_TOPIC, String(id));
} catch (e) { /* ignore */ }
}
return Dep.extend({ return Dep.extend({
template: 'knowledge-base:kb/index', template: 'knowledge-base:kb/index',
@@ -53,11 +71,13 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
// Active mode: 'search' (default) | 'ask' | 'browse' // Active mode: 'search' (default) | 'ask' | 'browse'
mode: 'search', mode: 'search',
kind: 'any', kind: 'any',
topicId: null,
data: function () { data: function () {
return { return {
mode: this.mode, mode: this.mode,
kind: this.kind, kind: this.kind,
topicId: this.topicId,
}; };
}, },
@@ -91,6 +111,12 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
e.preventDefault(); e.preventDefault();
this.clearResults(); this.clearResults();
}, },
'change select[data-name="topic"]': function (e) {
const raw = $(e.currentTarget).val();
const id = parseInt(raw, 10);
if (!Number.isFinite(id) || id === this.topicId) return;
this.switchTopic(id);
},
}, },
// Cancel any in-flight ask/search, drop the cached last-result for // Cancel any in-flight ask/search, drop the cached last-result for
@@ -133,28 +159,150 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
if (label && label !== 'KnowledgeBase') { if (label && label !== 'KnowledgeBase') {
document.title = label; document.title = label;
} }
// Best-effort initial topic id from localStorage. Reconciled with
// the actual topics list once it loads (a stale id from a deleted
// topic falls back to the first active topic).
this.topicId = _readPersistedTopicId();
// Kick off the topics fetch before first render so the <select>
// can be populated in the first afterRender pass without flicker.
this._ensureTopicsLoaded();
},
_ensureSourcesLoaded: function () {
const self = this;
if (this._sources) {
this.renderSourcesList();
return;
}
if (this._sourcesPromise || this.topicId == null) return;
const topicAtFetch = this.topicId;
this._sourcesPromise = Espo.Ajax.getRequest('KnowledgeBase/action/sources', {topicId: topicAtFetch})
.then(res => {
// Bail if the user switched topic before we landed.
if (self.topicId !== topicAtFetch) return;
self._sources = res.sources || [];
self.renderSourcesList();
})
.catch(err => {
console.error('KB: failed to load sources', err);
});
},
_ensureTopicsLoaded: function () {
if (_topics) return Promise.resolve(_topics);
if (_topicsPromise) return _topicsPromise;
_topicsPromise = Espo.Ajax.getRequest('KnowledgeBase/action/topics')
.then(res => {
_topics = (res && res.topics) || [];
return _topics;
})
.catch(err => {
console.error('KB: failed to load topics', err);
_topics = [];
return _topics;
})
.finally(() => { _topicsPromise = null; });
return _topicsPromise;
},
// Replace the <select> options with the loaded topics, reconcile
// this.topicId against the active list, and fire afterRender's
// mode-specific render path now that scope is known.
_populateTopicPicker: function () {
const $sel = this.$el.find('.kb-topic-select');
if (!$sel.length || !_topics) return;
const topics = _topics.filter(t => t.is_active !== false);
if (!topics.length) {
$sel.html('<option value="">אין נושאים פעילים</option>');
return;
}
// Reconcile: drop persisted id if it's not in the list any more.
const ids = topics.map(t => t.id);
if (!ids.includes(this.topicId)) {
this.topicId = topics[0].id;
_writePersistedTopicId(this.topicId);
}
const opts = topics.map(t => {
const sel = t.id === this.topicId ? ' selected' : '';
return '<option value="' + t.id + '"' + sel + '>' +
this.escape(t.name || ('#' + t.id)) + '</option>';
}).join('');
$sel.html(opts);
// Header suffix shows the active domain so the page title stays
// self-explanatory across topics.
const active = topics.find(t => t.id === this.topicId);
const $suffix = this.$el.find('.kb-topic-name-suffix');
if ($suffix.length) {
$suffix.text(active ? ' — ' + active.name : '');
}
},
// Called from the topic <select>'s change handler. Tear down anything
// scoped to the prior topic so cross-topic results never bleed in.
switchTopic: function (newTopicId) {
this.topicId = newTopicId;
_writePersistedTopicId(newTopicId);
// Invalidate everything scoped to the old topic.
_activeAsk = null;
_activeSearch = null;
_lastAsk = null;
_lastSearch = null;
_clear(sessionStorage, SS_ASK);
_clear(sessionStorage, SS_SEARCH);
this._sources = null;
this._sourcesPromise = null;
this.stopAskProgress();
this.setLoading(false);
this.$el.find('.kb-results').empty();
// Re-render the page so mode-specific markup picks up the new
// topic header. afterRender will re-fetch /sources for it.
this.reRender();
}, },
afterRender: function () { afterRender: function () {
// Load the source list up front so the browse tab is instant when const self = this;
// the user clicks it.
if (!this._sourcesPromise) { // Populate picker + scope-dependent fetches gate on topics being
this._sourcesPromise = Espo.Ajax.getRequest('KnowledgeBase/action/sources') // loaded so a stale localStorage id (deleted topic) doesn't fire
.then(res => { // a /sources?topicId=99 that 400s. Once topics resolve, the
this._sources = res.sources || []; // .then path reconciles this.topicId then runs the fetch.
this.renderSourcesList(); const afterTopics = () => {
}) if (!self.$el || !self.$el.length) return;
.catch(err => { self._populateTopicPicker();
console.error('KB: failed to load sources', err); self._ensureSourcesLoaded();
}); };
} else if (this._sources) { if (_topics) {
this.renderSourcesList(); afterTopics();
} else {
this._ensureTopicsLoaded().then(afterTopics);
} }
// Recover prior state across view remounts (user navigated away and // Recover prior state across view remounts (user navigated away and
// back). Active in-flight wins; otherwise replay the last result // back). Active in-flight wins; otherwise replay the last result
// from sessionStorage so the work the user already did doesn't // from sessionStorage so the work the user already did doesn't
// disappear behind a tab switch. // disappear behind a tab switch.
//
// CROSS-TOPIC GUARD: any cached state whose topicId doesn't match
// the current topic is silently dropped — its hits/answer were
// computed against a different domain.
if (_activeAsk && _activeAsk.topicId != null && _activeAsk.topicId !== this.topicId) {
try { if (_activeAsk.es) _activeAsk.es.close(); } catch (e) { /* ignore */ }
_activeAsk = null;
}
if (_lastAsk && _lastAsk.topicId != null && _lastAsk.topicId !== this.topicId) {
_lastAsk = null;
_clear(sessionStorage, SS_ASK);
}
if (_activeSearch && _activeSearch.topicId != null && _activeSearch.topicId !== this.topicId) {
_activeSearch = null;
}
if (_lastSearch && _lastSearch.topicId != null && _lastSearch.topicId !== this.topicId) {
_lastSearch = null;
_clear(sessionStorage, SS_SEARCH);
}
if (this.mode === 'ask') { if (this.mode === 'ask') {
if (_activeAsk) { if (_activeAsk) {
const $input = this.$el.find('input[data-name="query"]'); const $input = this.$el.find('input[data-name="query"]');
@@ -208,12 +356,14 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
_clear(sessionStorage, SS_SEARCH); _clear(sessionStorage, SS_SEARCH);
this.setLoading(true); this.setLoading(true);
const topicId = this.topicId;
const promise = Espo.Ajax.postRequest('KnowledgeBase/action/search', { const promise = Espo.Ajax.postRequest('KnowledgeBase/action/search', {
query: query, query: query,
kind: kind, kind: kind,
topK: 8, topK: 8,
topicId: topicId,
}); });
_activeSearch = {query: query, kind: kind, promise: promise}; _activeSearch = {query: query, kind: kind, topicId: topicId, promise: promise};
this._attachSearchHandlers(_activeSearch); this._attachSearchHandlers(_activeSearch);
}, },
@@ -224,6 +374,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
const entry = { const entry = {
query: search.query, query: search.query,
kind: search.kind, kind: search.kind,
topicId: search.topicId,
hits: hits, hits: hits,
selectedIdx: 0, selectedIdx: 0,
completedAt: Date.now(), completedAt: Date.now(),
@@ -252,15 +403,20 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
_clear(sessionStorage, SS_ASK); _clear(sessionStorage, SS_ASK);
this.setLoading(true); this.setLoading(true);
const topicId = this.topicId;
// Streaming via Server-Sent Events. The browser opens an // Streaming via Server-Sent Events. The browser opens an
// EventSource against KnowledgeBaseAskStream which proxies // EventSource against KnowledgeBaseAskStream which proxies
// /kb/ask/stream on shira-hermes; one SSE event per agent step // /kb/ask/stream on shira-hermes; one SSE event per agent step
// (thinking, tool_start, tool_done) plus a final answer event. // (thinking, tool_start, tool_done) plus a final answer event.
const url = '?entryPoint=KnowledgeBaseAskStream&message=' let url = '?entryPoint=KnowledgeBaseAskStream&message='
+ encodeURIComponent(message); + encodeURIComponent(message);
if (topicId != null) {
url += '&topicId=' + encodeURIComponent(topicId);
}
const es = new EventSource(url); const es = new EventSource(url);
_activeAsk = { _activeAsk = {
question: message, question: message,
topicId: topicId,
es: es, es: es,
events: [], events: [],
startedAt: Date.now(), startedAt: Date.now(),
@@ -286,6 +442,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
if (data.type === 'answer') { if (data.type === 'answer') {
self._finishAsk(ask, { self._finishAsk(ask, {
question: ask.question, question: ask.question,
topicId: ask.topicId,
text: data.text || '', text: data.text || '',
sources: data.sources || [], sources: data.sources || [],
completedAt: Date.now(), completedAt: Date.now(),
@@ -42,6 +42,27 @@ class KnowledgeBase
} }
} }
/**
* Coerce a request-supplied topic id (string from query, int from JSON body)
* to int|null. Lets the upstream resolver decide what an unknown id means.
*/
private function coerceTopicId($raw): ?int
{
if ($raw === null || $raw === '' || $raw === false) {
return null;
}
if (is_numeric($raw)) {
return (int) $raw;
}
return null;
}
public function getActionTopics(Request $request, Response $response): array
{
$this->checkAccess();
return $this->getService()->listTopics();
}
public function postActionSearch(Request $request, Response $response): array public function postActionSearch(Request $request, Response $response): array
{ {
$this->checkAccess(); $this->checkAccess();
@@ -54,14 +75,17 @@ class KnowledgeBase
return $this->getService()->search( return $this->getService()->search(
trim($data->query), trim($data->query),
$data->kind ?? 'any', $data->kind ?? 'any',
(int) ($data->top_k ?? $data->topK ?? 8) (int) ($data->top_k ?? $data->topK ?? 8),
$this->coerceTopicId($data->topicId ?? $data->topic_id ?? null)
); );
} }
public function getActionSources(Request $request, Response $response): array public function getActionSources(Request $request, Response $response): array
{ {
$this->checkAccess(); $this->checkAccess();
return $this->getService()->listSources(); return $this->getService()->listSources(
$this->coerceTopicId($request->getQueryParam('topicId'))
);
} }
public function getActionChunks(Request $request, Response $response): array public function getActionChunks(Request $request, Response $response): array
@@ -93,7 +117,8 @@ class KnowledgeBase
return $this->getService()->ask( return $this->getService()->ask(
trim($data->message), trim($data->message),
$data->conversationId ?? null, $data->conversationId ?? null,
$data->conversationHistory ?? [] $data->conversationHistory ?? [],
$this->coerceTopicId($data->topicId ?? $data->topic_id ?? null)
); );
} }
} }
@@ -46,6 +46,14 @@ class KnowledgeBaseAskStream implements EntryPoint
throw new BadRequest('message is too long.'); throw new BadRequest('message is too long.');
} }
// Optional topic scope. EventSource is GET-only so the client passes
// it via ?topicId=N; we forward it as topic_id in the JSON body to
// /kb/ask/stream. Bad ints just become null and the upstream falls
// back to the lowest active topic.
$topicIdRaw = $request->getQueryParam('topicId');
$topicId = (is_string($topicIdRaw) && is_numeric($topicIdRaw))
? (int) $topicIdRaw : null;
$service = $this->injectableFactory->create(KnowledgeBaseService::class); $service = $this->injectableFactory->create(KnowledgeBaseService::class);
try { try {
$upstreamUrl = $service->getStreamingUrl('/kb/ask/stream'); $upstreamUrl = $service->getStreamingUrl('/kb/ask/stream');
@@ -77,7 +85,11 @@ class KnowledgeBaseAskStream implements EntryPoint
// Defense in depth: same locked-down CSP we use for the PDF entry. // Defense in depth: same locked-down CSP we use for the PDF entry.
header("Content-Security-Policy: default-src 'self'"); header("Content-Security-Policy: default-src 'self'");
$payload = json_encode(['message' => $message], JSON_UNESCAPED_UNICODE); $payloadArr = ['message' => $message];
if ($topicId !== null) {
$payloadArr['topic_id'] = $topicId;
}
$payload = json_encode($payloadArr, JSON_UNESCAPED_UNICODE);
$ch = curl_init(); $ch = curl_init();
curl_setopt_array($ch, [ curl_setopt_array($ch, [
@@ -1,4 +1,12 @@
[ [
{
"route": "/KnowledgeBase/action/topics",
"method": "get",
"params": {
"controller": "KnowledgeBase",
"action": "topics"
}
},
{ {
"route": "/KnowledgeBase/action/search", "route": "/KnowledgeBase/action/search",
"method": "post", "method": "post",
@@ -29,23 +29,36 @@ class KnowledgeBaseService
$this->log = $log; $this->log = $log;
} }
public function search(string $query, string $kind, int $topK): array public function listTopics(): array
{
return $this->get('/kb/topics');
}
public function search(string $query, string $kind, int $topK, ?int $topicId = null): array
{ {
$topK = max(1, min(15, $topK)); $topK = max(1, min(15, $topK));
if (!in_array($kind, ['any', 'law', 'regulation', 'circular'], true)) { if (!in_array($kind, ['any', 'law', 'regulation', 'circular'], true)) {
$kind = 'any'; $kind = 'any';
} }
return $this->post('/kb/search', [ $payload = [
'query' => $query, 'query' => $query,
'kind' => $kind, 'kind' => $kind,
'top_k' => $topK, 'top_k' => $topK,
]); ];
if ($topicId !== null) {
$payload['topic_id'] = $topicId;
}
return $this->post('/kb/search', $payload);
} }
public function listSources(): array public function listSources(?int $topicId = null): array
{ {
return $this->get('/kb/sources'); $path = '/kb/sources';
if ($topicId !== null) {
$path .= '?topic_id=' . rawurlencode((string) $topicId);
}
return $this->get($path);
} }
public function sourceChunks(int $sourceId, ?int $around = null, int $ctx = 2): array public function sourceChunks(int $sourceId, ?int $around = null, int $ctx = 2): array
@@ -57,13 +70,21 @@ class KnowledgeBaseService
return $this->get($path); return $this->get($path);
} }
public function ask(string $message, ?string $conversationId, array $history): array public function ask(
{ string $message,
return $this->post('/kb/ask', [ ?string $conversationId,
array $history,
?int $topicId = null
): array {
$payload = [
'message' => $message, 'message' => $message,
'conversation_id' => $conversationId, 'conversation_id' => $conversationId,
'conversation_history' => $history, 'conversation_history' => $history,
]); ];
if ($topicId !== null) {
$payload['topic_id'] = $topicId;
}
return $this->post('/kb/ask', $payload);
} }
/** /**