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="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();
|
||||||
},
|
},
|
||||||
|
|
||||||
afterRender: function () {
|
_ensureSourcesLoaded: function () {
|
||||||
// Load the source list up front so the browse tab is instant when
|
const self = this;
|
||||||
// the user clicks it.
|
if (this._sources) {
|
||||||
if (!this._sourcesPromise) {
|
|
||||||
this._sourcesPromise = Espo.Ajax.getRequest('KnowledgeBase/action/sources')
|
|
||||||
.then(res => {
|
|
||||||
this._sources = res.sources || [];
|
|
||||||
this.renderSourcesList();
|
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 => {
|
.catch(err => {
|
||||||
console.error('KB: failed to load sources', err);
|
console.error('KB: failed to load sources', err);
|
||||||
});
|
});
|
||||||
} else if (this._sources) {
|
},
|
||||||
this.renderSourcesList();
|
|
||||||
|
_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 () {
|
||||||
|
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
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user