feat(KB): v0.8.0 — bulk upload, AI classifier, labels, tool kind
v0.8.0 ships the four-feature bundle from the v0.8.0 plan plus
mid-flight fixes discovered when batch-testing 5 circulars.
New capabilities:
* Multi-file batch upload. AI classifier (Sonnet via ai-gateway)
reads the first 3 pages of each PDF and proposes kind / title /
identifier / labels / dates / summary. User reviews + edits per
card before committing. Two-phase ingest: classify →
awaiting_review → embed.
* 'tool' kind for academic assessment instruments (GMFCS, MACS).
* Labels (kb_label / kb_source_label) for sub-topic navigation;
classifier proposes shared label slugs so the graph builds
itself.
* 'תוויות' admin sub-section for merge/delete of unused labels.
* NEW 'ממתינים לאישור' panel: lists batches still in
awaiting_review with status counters so a hard-refreshed user
can resume their review — closes the RAM-only _activeBatch gap.
Mid-flight fixes folded in:
* classifier timeout 45s→90s, concurrency 5→2. ai-gateway
serializes through a single Claude OAuth session; with conc=5,
jobs 4-5 of a 5-file batch consistently timed out.
* labels schema array-of-string → comma-separated string. Claude
via ai-gateway returned [{}, {}, {}] for items:{type:"string"}.
_normalize accepts both shapes defensively.
Backfilled ai_classified_at + description + 1-3 labels for the 10
sources ingested in earlier sessions so the filter UX is uniform.
Backend (shira-hermes) in commits 0d678da (Phase 6) + bb23b8a
(this session's fixes).
Refs Task Master #20
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -307,4 +307,161 @@ class KnowledgeBase
|
||||
}
|
||||
return $this->getService()->deleteAdminTopic((int) $id);
|
||||
}
|
||||
|
||||
// ── Phase 6 (v0.8.0): bulk upload + AI classifier + labels ──────────────
|
||||
|
||||
public function postActionUploadBatch(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
|
||||
// Multi-file: $_FILES['files'] is an array-of-arrays in PHP when
|
||||
// the form submits files[] repeated. Normalize per-file.
|
||||
$rawFiles = $_FILES['files'] ?? null;
|
||||
if (!$rawFiles || !is_array($rawFiles['tmp_name'] ?? null)) {
|
||||
throw new BadRequest('No files uploaded (form field must be `files[]`).');
|
||||
}
|
||||
$files = [];
|
||||
$count = count($rawFiles['tmp_name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$err = $rawFiles['error'][$i] ?? UPLOAD_ERR_NO_FILE;
|
||||
$tmp = $rawFiles['tmp_name'][$i] ?? '';
|
||||
if ($err !== UPLOAD_ERR_OK || !$tmp || !is_uploaded_file($tmp)) {
|
||||
continue; // server-side filter; client filters too
|
||||
}
|
||||
$files[] = [
|
||||
'tmp_name' => $tmp,
|
||||
'name' => (string) ($rawFiles['name'][$i] ?? 'upload'),
|
||||
'type' => (string) ($rawFiles['type'][$i] ?? 'application/octet-stream'),
|
||||
'size' => (int) ($rawFiles['size'][$i] ?? 0),
|
||||
];
|
||||
}
|
||||
if (!$files) {
|
||||
throw new BadRequest('No valid files in upload.');
|
||||
}
|
||||
|
||||
$kind = $_POST['kind'] ?? null;
|
||||
if (!in_array($kind, ['law', 'regulation', 'circular', 'caselaw', 'tool'], true)) {
|
||||
throw new BadRequest('kind must be one of: law, regulation, circular, caselaw, tool.');
|
||||
}
|
||||
$topicId = $this->coerceTopicId($_POST['topicId'] ?? $_POST['topic_id'] ?? null);
|
||||
if ($topicId === null) {
|
||||
throw new BadRequest('topicId is required.');
|
||||
}
|
||||
|
||||
return $this->getService()->uploadBatch(
|
||||
$files,
|
||||
(string) $kind,
|
||||
$topicId,
|
||||
(string) $this->user->get('userName'),
|
||||
);
|
||||
}
|
||||
|
||||
public function getActionBatch(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$batchId = $request->getQueryParam('batchId');
|
||||
if (!$batchId) {
|
||||
throw new BadRequest('batchId is required.');
|
||||
}
|
||||
return $this->getService()->getBatch((string) $batchId);
|
||||
}
|
||||
|
||||
public function getActionPendingBatches(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$limit = (int) ($request->getQueryParam('limit') ?? 50);
|
||||
if ($limit < 1) $limit = 1;
|
||||
if ($limit > 200) $limit = 200;
|
||||
return $this->getService()->listPendingBatches($limit);
|
||||
}
|
||||
|
||||
public function postActionCommitJob(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.');
|
||||
}
|
||||
$kind = $body->kind ?? null;
|
||||
if (!in_array($kind, ['law', 'regulation', 'circular', 'caselaw', 'tool'], true)) {
|
||||
throw new BadRequest('kind must be one of: law, regulation, circular, caselaw, tool.');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'kind' => $kind,
|
||||
'title' => isset($body->title) ? trim((string) $body->title) : '',
|
||||
'identifier' => isset($body->identifier) ? trim((string) $body->identifier) : null,
|
||||
'source_url' => $body->source_url ?? $body->sourceUrl ?? null,
|
||||
'published_at' => $body->published_at ?? $body->publishedAt ?? null,
|
||||
'effective_at' => $body->effective_at ?? $body->effectiveAt ?? null,
|
||||
'summary' => isset($body->summary) ? trim((string) $body->summary) : null,
|
||||
'label_slugs' => is_array($body->label_slugs ?? null) ? $body->label_slugs : [],
|
||||
'new_labels' => is_array($body->new_labels ?? null) ? $body->new_labels : [],
|
||||
];
|
||||
|
||||
return $this->getService()->commitJob(
|
||||
(int) $id, $payload, (string) $this->user->get('userName')
|
||||
);
|
||||
}
|
||||
|
||||
public function postActionDiscardJob(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()->discardJob(
|
||||
(int) $id, (string) $this->user->get('userName')
|
||||
);
|
||||
}
|
||||
|
||||
public function getActionLabels(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$topicId = $this->coerceTopicId($request->getQueryParam('topicId'));
|
||||
$q = $request->getQueryParam('q');
|
||||
$limit = $request->getQueryParam('limit');
|
||||
$limitInt = ($limit !== null && is_numeric($limit)) ? (int) $limit : 50;
|
||||
return $this->getService()->listLabels($topicId, $q, $limitInt);
|
||||
}
|
||||
|
||||
public function postActionCreateLabel(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$body = $request->getParsedBody();
|
||||
if (empty($body->slug) || empty($body->name)) {
|
||||
throw new BadRequest('slug and name are required.');
|
||||
}
|
||||
return $this->getService()->createLabel([
|
||||
'slug' => trim((string) $body->slug),
|
||||
'name' => trim((string) $body->name),
|
||||
'topic_id' => isset($body->topic_id) ? (int) $body->topic_id : null,
|
||||
], (string) $this->user->get('userName'));
|
||||
}
|
||||
|
||||
public function postActionMergeLabels(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$body = $request->getParsedBody();
|
||||
$id = $body->id ?? null;
|
||||
$intoId = $body->into_label_id ?? $body->intoLabelId ?? null;
|
||||
if (!$id || !is_numeric($id) || !$intoId || !is_numeric($intoId)) {
|
||||
throw new BadRequest('id and into_label_id (numeric) are required.');
|
||||
}
|
||||
return $this->getService()->mergeLabels((int) $id, (int) $intoId);
|
||||
}
|
||||
|
||||
public function postActionDeleteLabel(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()->deleteLabel((int) $id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,5 +126,77 @@
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "deleteTopic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/uploadBatch",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "uploadBatch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/batch",
|
||||
"method": "get",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "batch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/pendingBatches",
|
||||
"method": "get",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "pendingBatches"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/commitJob",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "commitJob"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/discardJob",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "discardJob"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/labels",
|
||||
"method": "get",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "labels"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/createLabel",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "createLabel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/mergeLabels",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "mergeLabels"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/KnowledgeBase/action/deleteLabel",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "KnowledgeBase",
|
||||
"action": "deleteLabel"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -227,6 +227,155 @@ class KnowledgeBaseService
|
||||
return $this->request('DELETE', '/admin/kb/topics/' . $topicId, null);
|
||||
}
|
||||
|
||||
// ── Phase 6 (v0.8.0): bulk upload + AI classifier + labels ──────────────
|
||||
|
||||
/**
|
||||
* Multi-file upload. Each file is sent to /admin/kb/upload-batch in
|
||||
* one multipart POST that repeats the `files` field. shira-hermes
|
||||
* assigns the batch_id and fires per-file classify tasks.
|
||||
*
|
||||
* @param array<int,array{tmp_name:string,name:string,type:string,size:int}> $files
|
||||
* @return array{batch_id:string, jobs:array<int,mixed>}
|
||||
*/
|
||||
public function uploadBatch(array $files, string $kind, int $topicId, string $username): array
|
||||
{
|
||||
if (!$files) {
|
||||
throw new BadRequest('No files in batch.');
|
||||
}
|
||||
$url = $this->getBaseUrl() . '/admin/kb/upload-batch';
|
||||
$apiKey = $this->getApiKey();
|
||||
if (!$apiKey) {
|
||||
throw new Error('SmartAssistant API key is not configured.');
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'kind' => $kind,
|
||||
'topic_id' => (string) $topicId,
|
||||
];
|
||||
// PHP cURL accepts repeated form fields by giving the array under
|
||||
// a special "[]" suffix syntax — but actually CURLOPT_POSTFIELDS
|
||||
// only sees the LAST value when the key repeats. Workaround:
|
||||
// build the multipart body manually so we can repeat `files`.
|
||||
$boundary = '----shira-hermes-' . bin2hex(random_bytes(8));
|
||||
$body = '';
|
||||
foreach ($fields as $k => $v) {
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Disposition: form-data; name=\"{$k}\"\r\n\r\n";
|
||||
$body .= $v . "\r\n";
|
||||
}
|
||||
foreach ($files as $f) {
|
||||
$contents = @file_get_contents($f['tmp_name']);
|
||||
if ($contents === false) {
|
||||
throw new Error("Failed to read uploaded file: " . $f['name']);
|
||||
}
|
||||
$name = addslashes($f['name']);
|
||||
$type = $f['type'] ?: $this->guessMime($f['name']);
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Disposition: form-data; name=\"files\"; filename=\"{$name}\"\r\n";
|
||||
$body .= "Content-Type: {$type}\r\n\r\n";
|
||||
$body .= $contents . "\r\n";
|
||||
}
|
||||
$body .= "--{$boundary}--\r\n";
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'Content-Type: multipart/form-data; boundary=' . $boundary,
|
||||
'X-Api-Key: ' . $apiKey,
|
||||
'X-User-Name: ' . $username,
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
// Batch upload of e.g. 10×10MB files is dominated by PHP→Python
|
||||
// network transfer; bump from the single-file 120s.
|
||||
CURLOPT_TIMEOUT => 300,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($err) {
|
||||
$this->log->error("KnowledgeBase: batch upload transport error: {$err}");
|
||||
throw new Error("Failed to reach Knowledge Base: {$err}");
|
||||
}
|
||||
if ($httpCode === 413) {
|
||||
throw new BadRequest('Total upload too large.');
|
||||
}
|
||||
if ($httpCode >= 400 && $httpCode < 500) {
|
||||
$detail = $this->decodeDetail($resp) ?: 'Bad request';
|
||||
throw new BadRequest($detail);
|
||||
}
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$this->log->error("KnowledgeBase: batch upload HTTP {$httpCode}: {$resp}");
|
||||
throw new Error("Knowledge Base returned HTTP {$httpCode}");
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $resp, true);
|
||||
if (!is_array($decoded) || !isset($decoded['batch_id'])) {
|
||||
throw new Error('Invalid response from Knowledge Base.');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public function getBatch(string $batchId): array
|
||||
{
|
||||
return $this->get('/admin/kb/batch/' . rawurlencode($batchId));
|
||||
}
|
||||
|
||||
public function listPendingBatches(int $limit): array
|
||||
{
|
||||
return $this->get('/admin/kb/batches?' . http_build_query(['limit' => $limit]));
|
||||
}
|
||||
|
||||
public function commitJob(int $jobId, array $payload, string $username): array
|
||||
{
|
||||
return $this->postWithUser(
|
||||
'/admin/kb/jobs/' . $jobId . '/commit',
|
||||
$payload,
|
||||
$username,
|
||||
);
|
||||
}
|
||||
|
||||
public function discardJob(int $jobId, string $username): array
|
||||
{
|
||||
return $this->postWithUser(
|
||||
'/admin/kb/jobs/' . $jobId . '/discard',
|
||||
null,
|
||||
$username,
|
||||
);
|
||||
}
|
||||
|
||||
public function listLabels(?int $topicId, ?string $q, int $limit): array
|
||||
{
|
||||
$qs = ['limit' => $limit];
|
||||
if ($topicId !== null) $qs['topic_id'] = $topicId;
|
||||
if ($q !== null && $q !== '') $qs['q'] = $q;
|
||||
return $this->get('/admin/kb/labels?' . http_build_query($qs));
|
||||
}
|
||||
|
||||
public function createLabel(array $payload, string $username): array
|
||||
{
|
||||
return $this->postWithUser('/admin/kb/labels', $payload, $username);
|
||||
}
|
||||
|
||||
public function mergeLabels(int $labelId, int $intoLabelId): array
|
||||
{
|
||||
return $this->request(
|
||||
'POST',
|
||||
'/admin/kb/labels/' . $labelId . '/merge',
|
||||
['into_label_id' => $intoLabelId],
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteLabel(int $labelId): array
|
||||
{
|
||||
return $this->request('DELETE', '/admin/kb/labels/' . $labelId, null);
|
||||
}
|
||||
|
||||
private function postWithUser(string $path, ?array $payload, string $username): array
|
||||
{
|
||||
$url = $this->getBaseUrl() . $path;
|
||||
|
||||
Reference in New Issue
Block a user