d051b268dd
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>
256 lines
9.2 KiB
PHP
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')
|
|
);
|
|
}
|
|
}
|