feat: re-process re-classifies AI metadata via review screen

Old behavior: clicking "עיבוד מחדש" on a source quietly re-chunked +
re-embedded the file but kept the existing metadata (title, identifier,
summary, labels). Sources whose original AI classification produced weak
metadata had no path to recover short of deleting and re-uploading.

New behavior: re-process now opens the same batch review screen as a
fresh upload — AI suggests fresh metadata (kind, title, identifier,
summary, labels, dates), the user reviews/edits, and committing
overwrites kb_source + replaces chunks. The card is tagged "עיבוד מחדש"
with a 🔄 icon so the user knows what they're committing.

Form precedence on the review card: user edits > AI suggestion > existing
source metadata. Existing labels are pre-filled in metadata_json and
surface as fallback if the AI classifier times out — so the form is
never blank, and existing labels are never silently dropped just because
the AI didn't re-suggest them.

Backend: shira-hermes 4ad569d.

Refs Task Master #22

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 17:11:57 +00:00
parent 6e578579b3
commit 8eac791c93
2 changed files with 55 additions and 21 deletions
@@ -833,18 +833,15 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
const src = this._findSourceById(id);
if (!src) return;
const titleShort = (src.title || '').slice(0, 60);
if (!confirm(`לעבד מחדש את "${titleShort}"?\n\nהקטעים הקיימים יימחקו והקובץ יעובר parse + chunk + embed מחדש.\nהמטא-דאטה (כותרת, מזהה, תאריכים) נשמרים.`)) return;
if (!confirm(`לעבד מחדש את "${titleShort}"?\n\nהקובץ ינותח מחדש על ידי AI; תקבל הצעות מטא-דאטה (כותרת, סיכום, תוויות) לעריכה במסך הסקירה.\nהקטעים יוחלפו רק לאחר שתאשר את ההצעות.`)) return;
const self = this;
Espo.Ajax.postRequest('KnowledgeBase/action/reingestSource', {id})
.then(res => {
if (res && res.job_id) {
// Surface in the upload-status banner + jobs list, like an upload would.
self._renderJobStatus({
original_filename: src.title || ('source #' + id),
status: res.status || 'queued',
});
self._startJobPolling(res.job_id);
// Refresh sources after the polling reports done.
if (res && res.batch_id) {
// Open the same review UI as a fresh upload — the
// backend created a single-file batch wrapping
// this re-ingest job.
self._openBatch(res.batch_id, src.topic_id || self.topicId);
}
})
.catch(err => {
@@ -1003,6 +1000,18 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
if (typeof ai === 'string') {
try { ai = JSON.parse(ai); } catch (e) { ai = {}; }
}
// For re-ingest jobs, metadata_json carries the
// existing source metadata so the form can pre-fill
// values (title/labels/etc.) before the AI classifier
// returns. AI suggestions take precedence once they
// arrive, but the fallback prevents an empty form
// during the classify wait — and keeps existing
// labels visible if the user wants to retain them.
let md = it.metadata_json;
if (typeof md === 'string') {
try { md = JSON.parse(md); } catch (e) { md = {}; }
}
const isReingest = !!(md && md.reingest);
let s = it.status;
if (s === 'processing' && it.processing_stage === 'classifying') s = 'classifying';
else if (s === 'processing' && it.processing_stage === 'embedding') s = 'committing';
@@ -1011,6 +1020,9 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
filename: it.original_filename,
status: s,
ai: (ai && Object.keys(ai).length) ? ai : null,
defaults: isReingest ? md : null,
isReingest: isReingest,
sourceId: it.source_id || null,
edits: null,
error: it.error_message || null,
};
@@ -1158,9 +1170,19 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
const html = _activeBatch.cards.map((c, idx) => {
if (c.status === 'awaiting_review') readyCount++;
const ai = c.ai || {};
const defaults = c.defaults || {};
const e = c.edits || {};
const val = (k) => this.escape(e[k] != null ? e[k] : (ai[k] || ''));
const labelsStr = e.labelsStr != null ? e.labelsStr : ((ai.labels || []).join(', '));
// Precedence: user edits > AI suggestion > existing source
// (only set for re-ingest cards). For fresh uploads `defaults`
// is empty so this collapses to the previous behavior.
const val = (k) => this.escape(
e[k] != null ? e[k] :
(ai[k] != null && ai[k] !== '' ? ai[k] :
(defaults[k] || ''))
);
const aiLabels = (ai.labels || []);
const fallbackLabels = aiLabels.length ? aiLabels : (defaults.labels || []);
const labelsStr = e.labelsStr != null ? e.labelsStr : fallbackLabels.join(', ');
let body = '';
if (c.status === 'failed') {
body = `<div class="text-danger small">${this.escape(c.error || 'שגיאה')}</div>`;
@@ -1181,9 +1203,13 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
} else {
body = '<div class="text-muted small">ממתין לניתוח…</div>';
}
const cardIcon = c.isReingest ? '🔄' : '📄';
const reingestTag = c.isReingest
? `<span class="label label-warning" style="font-size:10px;">עיבוד מחדש</span>`
: '';
return `<div class="kb-batch-card panel" data-card="${idx}" style="padding:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px;">
<div style="font-weight:500;">📄 ${this.escape(c.filename)}</div>
<div style="font-weight:500;">${cardIcon} ${this.escape(c.filename)} ${reingestTag}</div>
<div style="display:flex;gap:8px;align-items:center;">
${statusLabel(c.status)}
${c.status !== 'done' && c.status !== 'committing' ? `<button class="btn btn-link btn-sm" data-action="discardCard" data-jobid="${c.jobId || 0}" title="הסר" style="color:#d9534f;"><span class="glyphicon glyphicon-remove"></span></button>` : ''}
@@ -1294,8 +1320,16 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
if (!ready.length) return;
for (const card of ready) {
const ai = card.ai || {};
const defaults = card.defaults || {};
const e = card.edits || {};
const labelsStr = e.labelsStr != null ? e.labelsStr : ((ai.labels || []).join(', '));
// Same precedence as _renderBatchCards: edits > AI > defaults.
const pick = (k) =>
(e[k] != null ? e[k] :
(ai[k] != null && ai[k] !== '' ? ai[k] :
(defaults[k] || '')));
const aiLabels = (ai.labels || []);
const fallbackLabels = aiLabels.length ? aiLabels : (defaults.labels || []);
const labelsStr = e.labelsStr != null ? e.labelsStr : fallbackLabels.join(', ');
const newLabels = labelsStr
.split(',')
.map(s => s.trim())
@@ -1303,12 +1337,12 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
const payload = {
id: card.jobId,
kind: e.kind || ai.kind || _activeBatch.kind,
title: (e.title != null ? e.title : (ai.title || card.filename)).trim() || card.filename,
identifier: (e.identifier != null ? e.identifier : (ai.identifier || '')).trim() || null,
published_at: (e.published_at != null ? e.published_at : (ai.published_at || '')) || null,
effective_at: (e.effective_at != null ? e.effective_at : (ai.effective_at || '')) || null,
source_url: (e.source_url != null ? e.source_url : (ai.source_url || '')) || null,
summary: (e.summary != null ? e.summary : (ai.summary || '')) || null,
title: (pick('title') || card.filename).trim() || card.filename,
identifier: (pick('identifier') || '').trim() || null,
published_at: pick('published_at') || null,
effective_at: pick('effective_at') || null,
source_url: pick('source_url') || null,
summary: pick('summary') || null,
label_slugs: [],
new_labels: newLabels,
};
+2 -2
View File
@@ -1,14 +1,14 @@
{
"name": "KnowledgeBase",
"module": "KnowledgeBase",
"version": "0.8.1",
"version": "0.9.0",
"acceptableVersions": [
">=8.0.0"
],
"php": [
">=8.1"
],
"releaseDate": "2026-04-26",
"releaseDate": "2026-04-28",
"author": "klear",
"description": "Knowledge Base — Israeli National Insurance law, regulations, and circulars. Hybrid search + ask-shira, powered by shira-hermes KB.",
"displayLabel": "מאגר ידע"