This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
KnowledgeBase/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php
T
chaim ba75463661 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>
2026-04-25 14:15:36 +00:00

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)
);
}
}