feat: Phase 2 — document upload from KB UI

Adds a "ניהול" tab with multipart file upload, async job tracking, and
a recent-jobs list with live status polling. Browser POSTs to
/KnowledgeBase/action/upload (PHP proxy), which forwards as multipart
to shira-hermes /admin/kb/upload. shira-hermes returns a job_id
immediately and processes parse/chunk/embed in a background task; the
browser polls every 2s until status hits done|failed.

New EspoCRM endpoints:
  POST /KnowledgeBase/action/upload   (multipart, $_FILES['file'])
  GET  /KnowledgeBase/action/jobs     (list, filtered by topicId/status)
  GET  /KnowledgeBase/action/job?id   (single-job detail)

The X-User-Name header is forwarded so kb_ingest_job records who
uploaded each file. Cross-topic guard in switchTopic stops polling
when the user changes topics mid-upload (the job continues server-side).

Depends on shira-hermes commit 0cb89fb (admin upload endpoints +
migration 002).

Refs Task Master #13

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:46:42 +00:00
parent dacb7f6256
commit 6781ac4e37
7 changed files with 526 additions and 7 deletions
@@ -121,4 +121,74 @@ class KnowledgeBase
$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'], true)) {
throw new BadRequest('kind must be one of: law, regulation, circular.');
}
$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);
}
}
@@ -38,5 +38,29 @@
"controller": "KnowledgeBase",
"action": "ask"
}
},
{
"route": "/KnowledgeBase/action/upload",
"method": "post",
"params": {
"controller": "KnowledgeBase",
"action": "upload"
}
},
{
"route": "/KnowledgeBase/action/jobs",
"method": "get",
"params": {
"controller": "KnowledgeBase",
"action": "jobs"
}
},
{
"route": "/KnowledgeBase/action/job",
"method": "get",
"params": {
"controller": "KnowledgeBase",
"action": "job"
}
}
]
@@ -70,6 +70,136 @@ class KnowledgeBaseService
return $this->get($path);
}
/**
* Phase 2: async upload to /admin/kb/upload. Forwards the temp-uploaded
* file via cURL multipart and the EspoCRM username as `X-User-Name`
* so shira-hermes can record who uploaded what.
*
* @return array{job_id:int, status:string}
*/
public function uploadFile(
string $tmpPath,
string $filename,
string $kind,
int $topicId,
array $metadata,
string $username
): array {
if (!is_readable($tmpPath)) {
throw new Error('Uploaded file is not readable on disk.');
}
if (!in_array($kind, ['law', 'regulation', 'circular'], true)) {
throw new BadRequest('kind must be one of: law, regulation, circular.');
}
$url = $this->getBaseUrl() . '/admin/kb/upload';
$apiKey = $this->getApiKey();
if (!$apiKey) {
throw new Error('SmartAssistant API key is not configured.');
}
// Build the multipart form. CURLOPT_POSTFIELDS as an array makes
// cURL set the multipart Content-Type with a generated boundary.
$fields = [
'kind' => $kind,
'topic_id' => (string) $topicId,
'file' => new \CURLFile($tmpPath, $this->guessMime($filename), $filename),
];
foreach (['title', 'identifier', 'published_at', 'effective_at', 'source_url'] as $k) {
$val = $metadata[$k] ?? '';
if ($val !== '' && $val !== null) {
$fields[$k] = (string) $val;
}
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $fields,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'X-Api-Key: ' . $apiKey,
'X-User-Name: ' . $username,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_CONNECTTIMEOUT => 10,
]);
$body = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err) {
$this->log->error("KnowledgeBase: upload transport error: {$err}");
throw new Error("Failed to reach Knowledge Base: {$err}");
}
if ($httpCode === 413) {
throw new BadRequest('File too large (max 50MB).');
}
if ($httpCode >= 400 && $httpCode < 500) {
$detail = $this->decodeDetail($body) ?: 'Bad request';
throw new BadRequest($detail);
}
if ($httpCode < 200 || $httpCode >= 300) {
$this->log->error("KnowledgeBase: upload HTTP {$httpCode}: {$body}");
throw new Error("Knowledge Base returned HTTP {$httpCode}");
}
$decoded = json_decode((string) $body, true);
if (!is_array($decoded) || !isset($decoded['job_id'])) {
throw new Error('Invalid response from Knowledge Base.');
}
return $decoded;
}
public function listJobs(?int $topicId, ?string $status, int $limit): array
{
$limit = max(1, min(200, $limit));
$qs = ['limit' => $limit];
if ($topicId !== null) {
$qs['topic_id'] = $topicId;
}
if ($status !== null && $status !== '') {
$qs['status'] = $status;
}
$path = '/admin/kb/jobs?' . http_build_query($qs);
return $this->get($path);
}
public function getJob(int $jobId): array
{
return $this->get('/admin/kb/jobs/' . $jobId);
}
private function guessMime(string $filename): string
{
$lower = strtolower($filename);
if (str_ends_with($lower, '.pdf')) {
return 'application/pdf';
}
if (str_ends_with($lower, '.docx')) {
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
}
if (str_ends_with($lower, '.txt')) {
return 'text/plain';
}
return 'application/octet-stream';
}
private function decodeDetail($body): ?string
{
if (!is_string($body)) {
return null;
}
$decoded = json_decode($body, true);
if (is_array($decoded) && isset($decoded['detail'])) {
return is_string($decoded['detail']) ? $decoded['detail'] : json_encode($decoded['detail']);
}
return null;
}
public function ask(
string $message,
?string $conversationId,