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 d051b268dd feat: Phase 3 — sources management table in 'ניהול' tab
Adds full CRUD for KB sources from the EspoCRM UI: a sortable table at
the top of the 'ניהול' tab with kind / title-identifier-updated / chunk
count / actions columns, a kind filter (חוק / תקנות / חוזרים / פסיקה),
and three per-row actions:

  ✏ Edit       — inline form with title, identifier, dates, source URL
  ↻ Reingest   — re-parses the original file in place, replacing chunks
                 while keeping the source row + hand-edited metadata
  🗑 Delete    — confirm dialog with chunk count; double confirm above
                 100 chunks; also deletes the MinIO object

Browser polls the standard /KnowledgeBase/action/job endpoint for
re-ingest progress (same banner as upload). On terminal status
(done|failed) the sources table auto-refreshes so chunk_count and
last_ingest reflect the new state. Topic switch invalidates the
per-topic admin-sources cache so a stale list doesn't bleed across
topics.

Backend: depends on shira-hermes commit 17f93b5
(GET/PUT/DELETE /admin/kb/sources + POST /reingest).

Refs Task Master #14

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 17:27:45 +00:00

256 lines
9.2 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)
);
}
/**
* Multipart upload to shira-hermes /admin/kb/upload. We rely on PHP's
* automatic multipart parsing via $_FILES because the EspoCRM Request
* interface doesn't expose uploaded files directly. The user's
* EspoCRM identity is forwarded as `X-User-Name` so the upstream job
* row records who uploaded what.
*/
public function postActionUpload(Request $request, Response $response): array
{
$this->checkAccess();
$files = $_FILES['file'] ?? null;
if (!$files || ($files['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new BadRequest('No file uploaded (form field must be `file`).');
}
$tmpPath = $files['tmp_name'] ?? '';
if (!$tmpPath || !is_uploaded_file($tmpPath)) {
throw new BadRequest('Invalid upload — tmp_name missing.');
}
// For multipart/form-data EspoCRM's getParsedBody() returns an empty
// stdClass; the form fields land in $_POST as PHP parses them.
$kind = $_POST['kind'] ?? null;
if (!in_array($kind, ['law', 'regulation', 'circular', 'caselaw'], true)) {
throw new BadRequest('kind must be one of: law, regulation, circular, caselaw.');
}
$topicId = $this->coerceTopicId($_POST['topicId'] ?? $_POST['topic_id'] ?? null);
if ($topicId === null) {
throw new BadRequest('topicId is required.');
}
$metadata = [
'title' => isset($_POST['title']) ? trim((string) $_POST['title']) : '',
'identifier' => isset($_POST['identifier']) ? trim((string) $_POST['identifier']) : '',
'published_at' => $_POST['published_at'] ?? $_POST['publishedAt'] ?? '',
'effective_at' => $_POST['effective_at'] ?? $_POST['effectiveAt'] ?? '',
'source_url' => $_POST['source_url'] ?? $_POST['sourceUrl'] ?? '',
];
return $this->getService()->uploadFile(
$tmpPath,
(string) ($files['name'] ?? 'upload'),
(string) $kind,
$topicId,
$metadata,
(string) $this->user->get('userName')
);
}
public function getActionJobs(Request $request, Response $response): array
{
$this->checkAccess();
$topicId = $this->coerceTopicId($request->getQueryParam('topicId'));
$status = $request->getQueryParam('status');
$limit = $request->getQueryParam('limit');
$limitInt = ($limit !== null && is_numeric($limit)) ? (int) $limit : 50;
return $this->getService()->listJobs($topicId, $status, $limitInt);
}
public function getActionJob(Request $request, Response $response): array
{
$this->checkAccess();
$jobId = $request->getQueryParam('id');
if (!$jobId || !is_numeric($jobId)) {
throw new BadRequest('id (numeric) is required.');
}
return $this->getService()->getJob((int) $jobId);
}
// ── Phase 3: source management (Task #14) ───────────────────────────────
public function getActionAdminSources(Request $request, Response $response): array
{
$this->checkAccess();
$topicId = $this->coerceTopicId($request->getQueryParam('topicId'));
$kind = $request->getQueryParam('kind');
$limit = $request->getQueryParam('limit');
$limitInt = ($limit !== null && is_numeric($limit)) ? (int) $limit : 200;
return $this->getService()->listAdminSources($topicId, $kind, $limitInt);
}
public function postActionUpdateSource(Request $request, Response $response): array
{
$this->checkAccess();
$body = $request->getParsedBody();
$id = $body->id ?? null;
if (!$id || !is_numeric($id)) {
throw new BadRequest('id (numeric) is required.');
}
// Whitelist what we forward — never let arbitrary body fields hit
// upstream's PUT body. Empty strings are explicitly preserved as
// null clearing (the user wiping a date or identifier).
$payload = [];
foreach (['title', 'identifier', 'source_url', 'sourceUrl', 'published_at', 'publishedAt', 'effective_at', 'effectiveAt'] as $k) {
if (property_exists($body, $k)) {
$canon = $k;
if ($k === 'sourceUrl') $canon = 'source_url';
if ($k === 'publishedAt') $canon = 'published_at';
if ($k === 'effectiveAt') $canon = 'effective_at';
$payload[$canon] = $body->$k;
}
}
return $this->getService()->updateAdminSource((int) $id, $payload);
}
public function postActionDeleteSource(Request $request, Response $response): array
{
$this->checkAccess();
$body = $request->getParsedBody();
$id = $body->id ?? null;
if (!$id || !is_numeric($id)) {
throw new BadRequest('id (numeric) is required.');
}
return $this->getService()->deleteAdminSource((int) $id);
}
public function postActionReingestSource(Request $request, Response $response): array
{
$this->checkAccess();
$body = $request->getParsedBody();
$id = $body->id ?? null;
if (!$id || !is_numeric($id)) {
throw new BadRequest('id (numeric) is required.');
}
return $this->getService()->reingestAdminSource(
(int) $id,
(string) $this->user->get('userName')
);
}
}