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="page-header">
<h3>בסיס ידע — ביטוח לאומי</h3>
<div class="page-header" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;">
<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>
<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
// also recovers it. None of this leaks to the next browser session —
// ask history is intentionally ephemeral.
let _activeAsk = null; // {question, promise, startedAt} while in flight
let _lastAsk = null; // {question, text, sources, completedAt} after success
let _activeSearch = null; // {query, kind, promise} while in flight
let _lastSearch = null; // {query, kind, hits, selectedIdx, completedAt}
let _activeAsk = null; // {question, topicId, promise, startedAt} while in flight
let _lastAsk = null; // {question, topicId, text, sources, completedAt} after success
let _activeSearch = null; // {query, kind, topicId, promise} while in flight
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_SEARCH = 'kb-last-search';
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
// 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.
@@ -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));
}
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({
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'
mode: 'search',
kind: 'any',
topicId: null,
data: function () {
return {
mode: this.mode,
kind: this.kind,
topicId: this.topicId,
};
},
@@ -91,6 +111,12 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
e.preventDefault();
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
@@ -133,28 +159,150 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
if (label && label !== 'KnowledgeBase') {
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 () {
// 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();
const self = this;
// Populate picker + scope-dependent fetches gate on topics being
// loaded so a stale localStorage id (deleted topic) doesn't fire
// a /sources?topicId=99 that 400s. Once topics resolve, the
// .then path reconciles this.topicId then runs the fetch.
const afterTopics = () => {
if (!self.$el || !self.$el.length) return;
self._populateTopicPicker();
self._ensureSourcesLoaded();
};
if (_topics) {
afterTopics();
} else {
this._ensureTopicsLoaded().then(afterTopics);
}
// Recover prior state across view remounts (user navigated away and
// back). Active in-flight wins; otherwise replay the last result
// from sessionStorage so the work the user already did doesn't
// 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 (_activeAsk) {
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);
this.setLoading(true);
const topicId = this.topicId;
const promise = Espo.Ajax.postRequest('KnowledgeBase/action/search', {
query: query,
kind: kind,
topK: 8,
topicId: topicId,
});
_activeSearch = {query: query, kind: kind, promise: promise};
_activeSearch = {query: query, kind: kind, topicId: topicId, promise: promise};
this._attachSearchHandlers(_activeSearch);
},
@@ -224,6 +374,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
const entry = {
query: search.query,
kind: search.kind,
topicId: search.topicId,
hits: hits,
selectedIdx: 0,
completedAt: Date.now(),
@@ -252,15 +403,20 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
_clear(sessionStorage, SS_ASK);
this.setLoading(true);
const topicId = this.topicId;
// Streaming via Server-Sent Events. The browser opens an
// EventSource against KnowledgeBaseAskStream which proxies
// /kb/ask/stream on shira-hermes; one SSE event per agent step
// (thinking, tool_start, tool_done) plus a final answer event.
const url = '?entryPoint=KnowledgeBaseAskStream&message='
let url = '?entryPoint=KnowledgeBaseAskStream&message='
+ encodeURIComponent(message);
if (topicId != null) {
url += '&topicId=' + encodeURIComponent(topicId);
}
const es = new EventSource(url);
_activeAsk = {
question: message,
topicId: topicId,
es: es,
events: [],
startedAt: Date.now(),
@@ -286,6 +442,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
if (data.type === 'answer') {
self._finishAsk(ask, {
question: ask.question,
topicId: ask.topicId,
text: data.text || '',
sources: data.sources || [],
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
{
$this->checkAccess();
@@ -54,14 +75,17 @@ class KnowledgeBase
return $this->getService()->search(
trim($data->query),
$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
{
$this->checkAccess();
return $this->getService()->listSources();
return $this->getService()->listSources(
$this->coerceTopicId($request->getQueryParam('topicId'))
);
}
public function getActionChunks(Request $request, Response $response): array
@@ -93,7 +117,8 @@ class KnowledgeBase
return $this->getService()->ask(
trim($data->message),
$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.');
}
// 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);
try {
$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.
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();
curl_setopt_array($ch, [
@@ -1,4 +1,12 @@
[
{
"route": "/KnowledgeBase/action/topics",
"method": "get",
"params": {
"controller": "KnowledgeBase",
"action": "topics"
}
},
{
"route": "/KnowledgeBase/action/search",
"method": "post",
@@ -29,23 +29,36 @@ class KnowledgeBaseService
$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));
if (!in_array($kind, ['any', 'law', 'regulation', 'circular'], true)) {
$kind = 'any';
}
return $this->post('/kb/search', [
$payload = [
'query' => $query,
'kind' => $kind,
'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
@@ -57,13 +70,21 @@ class KnowledgeBaseService
return $this->get($path);
}
public function ask(string $message, ?string $conversationId, array $history): array
{
return $this->post('/kb/ask', [
public function ask(
string $message,
?string $conversationId,
array $history,
?int $topicId = null
): array {
$payload = [
'message' => $message,
'conversation_id' => $conversationId,
'conversation_history' => $history,
]);
];
if ($topicId !== null) {
$payload['topic_id'] = $topicId;
}
return $this->post('/kb/ask', $payload);
}
/**