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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user