ba75463661
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>
125 lines
3.7 KiB
PHP
125 lines
3.7 KiB
PHP
<?php
|
|
|
|
namespace Espo\Modules\KnowledgeBase\Controllers;
|
|
|
|
use Espo\Core\Api\Request;
|
|
use Espo\Core\Api\Response;
|
|
use Espo\Core\Exceptions\BadRequest;
|
|
use Espo\Core\Exceptions\Forbidden;
|
|
use Espo\Core\InjectableFactory;
|
|
use Espo\Core\Acl;
|
|
use Espo\Modules\KnowledgeBase\Services\KnowledgeBaseService;
|
|
use Espo\Entities\User;
|
|
|
|
/**
|
|
* Thin proxy controller. The EspoCRM UI talks to these actions; each
|
|
* action forwards to shira-hermes (/kb/*) with the shared API key held
|
|
* server-side. The browser never sees the shira-hermes key.
|
|
*/
|
|
class KnowledgeBase
|
|
{
|
|
private InjectableFactory $injectableFactory;
|
|
private Acl $acl;
|
|
private User $user;
|
|
|
|
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user)
|
|
{
|
|
$this->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.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
$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),
|
|
$this->coerceTopicId($data->topicId ?? $data->topic_id ?? null)
|
|
);
|
|
}
|
|
|
|
public function getActionSources(Request $request, Response $response): array
|
|
{
|
|
$this->checkAccess();
|
|
return $this->getService()->listSources(
|
|
$this->coerceTopicId($request->getQueryParam('topicId'))
|
|
);
|
|
}
|
|
|
|
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.');
|
|
}
|
|
|
|
$around = $request->getQueryParam('around');
|
|
$ctx = $request->getQueryParam('ctx');
|
|
$aroundInt = ($around !== null && is_numeric($around)) ? (int) $around : null;
|
|
$ctxInt = ($ctx !== null && is_numeric($ctx)) ? (int) $ctx : 2;
|
|
|
|
return $this->getService()->sourceChunks((int) $sourceId, $aroundInt, $ctxInt);
|
|
}
|
|
|
|
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 ?? [],
|
|
$this->coerceTopicId($data->topicId ?? $data->topic_id ?? null)
|
|
);
|
|
}
|
|
}
|