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:
2026-04-25 20:18:34 +00:00
parent 30a9c397e8
commit 8a4f16a50f
7 changed files with 1020 additions and 66 deletions
@@ -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;