Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/de/platform/knowledge/documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Das Löschen eines Ordners löscht jede Datei und jeden Unterordner darin endgü

**Neu indexieren** (Zeilenmenü) lässt die Pipeline erneut über die gespeicherte Datei laufen — der richtige Zug nach einem Indexierungsfehler oder wenn ein Dokument **Neuindexierung nötig** zeigt. **Löschen** entfernt das Dokument und seine indexierten Chunks; die Bestätigung sagt es unumwunden — die Aktion lässt sich nicht rückgängig machen. Dieselbe Datei erneut hochzuladen bringt den Inhalt als frisches Dokument zurück. Ein gelenktes Dokument lässt sich nicht mehr löschen, sobald irgendeine Version freigegeben wurde — im Review, freigegeben oder mit offenem nächsten Entwurf zeigt der Menüeintrag stattdessen **Geschütztes gelenktes Dokument**, und ein Ordner mit so einem Datensatz verweigert das Ordner-Löschen genauso. Der freigegebene Stand ist ein aufbewahrtes Dokument; genau dafür gibt es den Lebenszyklus.

Jedes Dokument zeigt einen Status: **In Warteschlange** (wartet — eine ausgelastete Organisation indexiert einige Dateien gleichzeitig, der Rest reiht sich ein), **Wird indexiert**, **Indexiert**, **Fehlgeschlagen** oder **Nicht unterstützt** (ein Altformat wie `.doc`/`.ppt`/`.xls`, das sich problemlos speichern und herunterladen lässt, aber keinen Text-Extraktor hat und daher nie für die Suche indexiert wird). Ein durch ein Zeitlimit oder einen Backend-Neustart unterbrochener Indexierungsvorgang erholt sich innerhalb weniger Minuten von selbst — er wird wiederholt oder als **Fehlgeschlagen** mit Wiederholen-Option markiert, nie steckengelassen. Wenn deine Organisation ein Speicher-Kontingent pro Nutzer durchsetzt, zählen fehlgeschlagene und nicht unterstützte Dateien weiterhin dagegen, bis sie gelöscht werden — Platz schaffen heißt also, nicht mehr benötigte Dateien zu entfernen.
Jedes Dokument zeigt einen Status: **In Warteschlange** (wartet — eine ausgelastete Organisation indexiert einige Dateien gleichzeitig, der Rest reiht sich ein), **Wird indexiert**, **Indexiert**, **Fehlgeschlagen** oder **Nicht unterstützt** (ein Altformat wie `.doc`/`.ppt`/`.xls` oder ein Bild wie `.png`/`.jpg` — lässt sich problemlos speichern und herunterladen, hat aber keinen Text-Extraktor und wird daher nie für die Suche indexiert). Ein durch ein Zeitlimit oder einen Backend-Neustart unterbrochener Indexierungsvorgang erholt sich innerhalb weniger Minuten von selbst — er wird wiederholt oder als **Fehlgeschlagen** mit Wiederholen-Option markiert, nie steckengelassen. Wenn deine Organisation ein Speicher-Kontingent pro Nutzer durchsetzt, zählen fehlgeschlagene und nicht unterstützte Dateien weiterhin dagegen, bis sie gelöscht werden — Platz schaffen heißt also, nicht mehr benötigte Dateien zu entfernen.

Ein Klick auf ein Dokument öffnet die Vorschau, mit einer Seitenleiste für Größe, Quelle, RAG-Status, Teams, hochladende Person und Änderungsdatum — der schnellste Weg zu prüfen, worauf ein Zitat wirklich zeigt.

Expand Down
2 changes: 1 addition & 1 deletion docs/en/platform/knowledge/documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Deleting a folder permanently deletes every file and subfolder inside it. Deleti

**Reindex** (row menu) re-runs the pipeline on the stored file — the right move after an indexing failure or when a document shows **Needs reindex**. **Delete** removes the document and its indexed chunks; the confirmation says it plainly — the action cannot be undone. Re-uploading the same file brings the content back as a fresh document. A controlled record stops being deletable the moment any of its versions is approved — in review, approved, or drafting the next revision, the menu entry reads **Protected controlled record** instead, and a folder holding such a record refuses folder deletion the same way. The approved snapshot is a retained record; that is the point of the lifecycle.

Each document shows a status: **Queued** (waiting its turn — a busy organization indexes a few files at a time and the rest queue), **Indexing**, **Indexed**, **Failed**, or **Unsupported** (a legacy format such as `.doc`/`.ppt`/`.xls` that stores and downloads fine but has no text extractor, so it is never indexed for search). An indexing job interrupted by a timeout or a backend restart recovers on its own within a few minutes — it is retried or marked **Failed** with a retry option, never left stuck. If your organization enforces a per-user storage quota, failed and unsupported files still count against it until deleted, so freeing space means removing files you no longer need.
Each document shows a status: **Queued** (waiting its turn — a busy organization indexes a few files at a time and the rest queue), **Indexing**, **Indexed**, **Failed**, or **Unsupported** (a legacy format such as `.doc`/`.ppt`/`.xls`, or an image such as `.png`/`.jpg` — it stores and downloads fine but has no text extractor, so it is never indexed for search). An indexing job interrupted by a timeout or a backend restart recovers on its own within a few minutes — it is retried or marked **Failed** with a retry option, never left stuck. If your organization enforces a per-user storage quota, failed and unsupported files still count against it until deleted, so freeing space means removing files you no longer need.

Clicking a document opens the preview, with a sidebar showing size, source, RAG status, teams, uploader, and modification date — the fastest way to check what a citation actually points at.

Expand Down
2 changes: 1 addition & 1 deletion docs/fr/platform/knowledge/documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Supprimer un dossier supprime définitivement chaque fichier et sous-dossier qu

**Réindexer** (menu de la ligne) refait passer le pipeline sur le fichier stocké — le bon geste après un échec d’indexation ou quand un document affiche **Réindexation nécessaire**. **Supprimer** retire le document et ses fragments indexés ; la confirmation le dit sans détour — l’action est irréversible. Retéléverser le même fichier ramène le contenu sous la forme d’un nouveau document. Un document maîtrisé cesse d’être supprimable dès qu’une de ses versions est approuvée — en relecture, approuvé ou avec le brouillon suivant ouvert, l’entrée du menu affiche **Document maîtrisé protégé**, et un dossier qui en contient un refuse la suppression du dossier de la même façon. L’instantané approuvé est un enregistrement conservé ; c’est précisément le rôle du cycle de vie.

Chaque document affiche un statut : **En file** (en attente — une organisation chargée indexe quelques fichiers à la fois et le reste patiente), **Indexation**, **Indexé**, **Échoué** ou **Non pris en charge** (un ancien format comme `.doc`/`.ppt`/`.xls` qui se stocke et se télécharge sans souci mais n’a pas d’extracteur de texte, donc jamais indexé pour la recherche). Une indexation interrompue par un délai dépassé ou un redémarrage du backend se rétablit d’elle-même en quelques minutes — elle est relancée ou marquée **Échoué** avec une option de reprise, jamais laissée bloquée. Si ton organisation applique un quota de stockage par utilisateur, les fichiers échoués et non pris en charge comptent toujours dedans jusqu’à leur suppression : libérer de l’espace revient donc à retirer les fichiers dont tu n’as plus besoin.
Chaque document affiche un statut : **En file** (en attente — une organisation chargée indexe quelques fichiers à la fois et le reste patiente), **Indexation**, **Indexé**, **Échoué** ou **Non pris en charge** (un ancien format comme `.doc`/`.ppt`/`.xls`, ou une image comme `.png`/`.jpg` — ça se stocke et se télécharge sans souci mais n’a pas d’extracteur de texte, donc jamais indexé pour la recherche). Une indexation interrompue par un délai dépassé ou un redémarrage du backend se rétablit d’elle-même en quelques minutes — elle est relancée ou marquée **Échoué** avec une option de reprise, jamais laissée bloquée. Si ton organisation applique un quota de stockage par utilisateur, les fichiers échoués et non pris en charge comptent toujours dedans jusqu’à leur suppression : libérer de l’espace revient donc à retirer les fichiers dont tu n’as plus besoin.

Cliquer sur un document ouvre l’aperçu, avec un panneau latéral qui montre la taille, la source, le statut RAG, les équipes, l’auteur du téléversement et la date de modification — le moyen le plus rapide de vérifier ce que vise réellement une citation.

Expand Down
56 changes: 55 additions & 1 deletion services/platform/backend/core/lib/file_io.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @vitest-environment node

import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
Expand All @@ -12,7 +14,10 @@ import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { atomicWriteSecret } from './file_io';
import { atomicWriteSecret, readJsonFile } from './file_io';

/** Root bypasses file permissions, so the EACCES lane cannot be produced. */
const IS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;

let dir: string;
let prevUmask: number;
Expand Down Expand Up @@ -73,3 +78,52 @@ describe('atomicWriteSecret', () => {
expect(remaining).toEqual([path.basename(target)]);
});
});

describe('readJsonFile', () => {
const parse = (content: string): unknown => JSON.parse(content);

it('reads and hashes a well-formed file', async () => {
const target = path.join(dir, 'config.json');
writeFileSync(target, '{"a":1}');
const result = await readJsonFile(target, 1024, parse);
expect(result.ok).toBe(true);
if (result.ok) expect(result.data).toEqual({ a: 1 });
});

it('labels a genuinely missing file not_found', async () => {
const result = await readJsonFile(path.join(dir, 'nope.json'), 1024, parse);
expect(result).toMatchObject({ ok: false, error: 'not_found' });
});

it('labels a path through a regular file not_found (ENOTDIR)', async () => {
const file = path.join(dir, 'file.txt');
writeFileSync(file, 'x');
const result = await readJsonFile(
path.join(file, 'config.json'),
1024,
parse,
);
expect(result).toMatchObject({ ok: false, error: 'not_found' });
});

// Regression: every stat() failure used to read as `not_found`, so a
// mis-permissioned config volume silently downgraded governance policies
// to their defaults. Only ENOENT/ENOTDIR are "absent"; EACCES is a fault.
it.skipIf(IS_ROOT)(
'labels a present-but-unreadable file inaccessible, not not_found',
async () => {
const locked = path.join(dir, 'locked');
mkdirSync(locked);
const target = path.join(locked, 'config.json');
writeFileSync(target, '{"a":1}');
chmodSync(locked, 0o000);
try {
const result = await readJsonFile(target, 1024, parse);
expect(result).toMatchObject({ ok: false, error: 'inaccessible' });
if (!result.ok) expect(result.message).toMatch(/EACCES|permission/i);
} finally {
chmodSync(locked, 0o700);
}
},
);
});
27 changes: 16 additions & 11 deletions services/platform/backend/core/lib/file_io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,11 +378,23 @@ export async function readJsonFile<T>(
let fileStat;
try {
fileStat = await stat(filePath);
} catch {
} catch (err) {
// Only a genuinely missing file is `not_found` (a component that is not
// a directory means the same thing). A permission or I/O failure is
// `inaccessible`: callers treat `not_found` as "use the defaults", and a
// mis-permissioned config volume must surface, never read as absent.
const code = errnoCode(err);
if (code === 'ENOENT' || code === 'ENOTDIR') {
return {
ok: false,
error: 'not_found',
message: `File not found: ${path.basename(filePath)}`,
};
}
return {
ok: false,
error: 'not_found',
message: `File not found: ${path.basename(filePath)}`,
error: 'inaccessible',
message: `Failed to stat file: ${path.basename(filePath)} — ${err instanceof Error ? err.message : String(err)}`,
};
}

Expand All @@ -403,16 +415,9 @@ export async function readJsonFile<T>(
await fd.close();
}
} catch (err) {
const code = err instanceof Error && 'code' in err ? err.code : undefined;
const errorType =
code === 'ENOENT'
? 'not_found'
: code === 'EACCES' || code === 'EPERM'
? 'inaccessible'
: 'inaccessible';
return {
ok: false,
error: errorType,
error: errnoCode(err) === 'ENOENT' ? 'not_found' : 'inaccessible',
message: `Failed to read file: ${path.basename(filePath)} — ${err instanceof Error ? err.message : String(err)}`,
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import JSZip from 'jszip';
import { describe, expect, it } from 'vitest';

import { ALL_SUPPORTED_EXTENSIONS, extractText, isSupported } from './router';
import {
ALL_SUPPORTED_EXTENSIONS,
extractText,
isImageFile,
isSupported,
} from './router';

const enc = (s: string): Uint8Array => new TextEncoder().encode(s);

Expand Down Expand Up @@ -100,3 +105,26 @@ describe('ALL_SUPPORTED_EXTENSIONS', () => {
}
});
});

describe('isImageFile', () => {
it('recognizes every image extension the router routes to the vision extractor', () => {
for (const f of [
'photo.png',
'PHOTO.JPG',
'scan.jpeg',
'anim.gif',
'pic.webp',
'raw.bmp',
'scan.tiff',
'scan.tif',
]) {
expect(isImageFile(f)).toBe(true);
}
});

it('is false for every non-image format, supported or not', () => {
for (const f of ['doc.pdf', 'doc.docx', 'notes.md', 'data.xlsx', 'x.exe']) {
expect(isImageFile(f)).toBe(false);
}
});
});
11 changes: 11 additions & 0 deletions services/platform/backend/core/lib/knowledge/extraction/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ export function isSupported(filename: string): boolean {
return ALL_SUPPORTED_EXTENSIONS.has(extname(filename).toLowerCase());
}

/**
* Does this file route to the IMAGE extractor? Image extraction is entirely
* vision-backed (`extractTextFromImageBytes` yields '' without a
* `VisionClient`), so a caller with no vision lane can decide up front that
* the file has nothing it can index — instead of downloading, extracting
* nothing, and reporting a failure.
*/
export function isImageFile(filename: string): boolean {
return SUPPORTED_IMAGE_EXTENSIONS.has(extname(filename).toLowerCase());
}

export interface ExtractTextOptions {
visionClient?: VisionClient | null;
processImages?: boolean;
Expand Down
Loading
Loading