Recheck project filenames and Word commenters
This commit is contained in:
@@ -4,7 +4,7 @@ Raycast-Extension für PII-sichere Text- und Projekt-Workflows mit Velum: Inhalt
|
|||||||
|
|
||||||
## Projektdateien
|
## Projektdateien
|
||||||
|
|
||||||
`Projektdateien pseudonymisieren` übernimmt zuerst den geöffneten Finder-Ordner; bei Bedarf kann er im Formular erneut übernommen oder manuell gewählt werden. Im Formular werden die zu erkennenden Kategorien ausgewählt (Orte sind wegen häufiger Fehlalarme zunächst abgewählt). Danach analysiert Velum alle geeigneten Text-, DOCX-, XLSX-, PPTX- und PDF-Dateien sowie ihre Namen. Kleine Textdateien und Namen werden in Paketen analysiert, um die Anzahl der API-Aufrufe zu verringern. Die Prüfung zeigt jede eindeutige Zuordnung einmal, nach Kategorie gruppiert, mit der Anzahl ihrer Vorkommen. Eine Zuordnung an- oder abzuwählen gilt für alle betroffenen Dateien; einzelne Vorkommen lassen sich bei Bedarf ansehen. Nach der Auswahl lassen sich erkannte Personen optional zusammenfassen, sodass ihre Erwähnungen denselben Platzhalter erhalten. `Pseudonymisierte Kopien erstellen` schreibt Dateien in den Raycast-Support-Bereich außerhalb des Projektordners. Alternativ ersetzt `Originaldateien ersetzen` die Dateien und gegebenenfalls ihre Namen vor Ort; zuvor legt Velum eine private Sicherung der Originale im Raycast-Support-Bereich an. PDF wird dabei als DOCX ausgegeben.
|
`Projektdateien pseudonymisieren` übernimmt zuerst den geöffneten Finder-Ordner; bei Bedarf kann er im Formular erneut übernommen oder manuell gewählt werden. Im Formular werden die zu erkennenden Kategorien ausgewählt (Orte sind wegen häufiger Fehlalarme zunächst abgewählt). Danach analysiert Velum alle geeigneten Text-, DOCX-, XLSX-, PPTX- und PDF-Dateien sowie ihre Namen. Unterstriche zwischen Namensbestandteilen werden bei der Erkennung als Worttrenner behandelt; bekannte Platzhalter und wörtliche Codebegriffe bleiben erhalten. Auch Namen unveränderter Dateien werden in einem Folgelauf erneut geprüft. Ältere Word-Projektstände werden einmalig auf Kommentatoren geprüft, sofern `PERSON` ausgewählt ist. Kleine Textdateien und Namen werden in Paketen analysiert, um die Anzahl der API-Aufrufe zu verringern. Die Prüfung zeigt jede eindeutige Zuordnung einmal, nach Kategorie gruppiert, mit der Anzahl ihrer Vorkommen. Eine Zuordnung an- oder abzuwählen gilt für alle betroffenen Dateien; einzelne Vorkommen lassen sich bei Bedarf ansehen. Nach der Auswahl lassen sich erkannte Personen optional zusammenfassen, sodass ihre Erwähnungen denselben Platzhalter erhalten. `Pseudonymisierte Kopien erstellen` schreibt Dateien in den Raycast-Support-Bereich außerhalb des Projektordners. Alternativ ersetzt `Originaldateien ersetzen` die Dateien und gegebenenfalls ihre Namen vor Ort; zuvor legt Velum eine private Sicherung der Originale im Raycast-Support-Bereich an. PDF wird dabei als DOCX ausgegeben.
|
||||||
|
|
||||||
Projektbegriffe werden zeilenweise als `BEGRIFF: KundenSuite` oder `PERSON: Max Mustermann` eingegeben, nur für dieses Projekt gespeichert und mit `force_masks` pro API-Aufruf übertragen. Die globale Velum-Regelliste bleibt unverändert. Beim Erstellen eines Projektstands werden abgewählte Treffer als typgebundene Projekt-Whitelist außerhalb des Quellordners gespeichert und bei späteren Läufen nicht erneut angeboten. Die Whitelist lässt sich im Startformular bearbeiten. Eine neue Projektbearbeitung übernimmt die bestätigten Zuordnungen des letzten Projektstands, sodass Platzhalter konsistent bleiben. Nach einem Lauf mit Kopien bleibt der Klartext-Quellordner erhalten und kann nach Dateiänderungen erneut bearbeitet werden. Nach dem Ersetzen vor Ort werden unveränderte bereits maskierte Dateien in den nächsten Stand übernommen; neue oder geänderte Dateien werden analysiert. Ein neuer Projektstand enthält auch die übernommenen Dateien für die Rückübersetzung.
|
Projektbegriffe werden zeilenweise als `BEGRIFF: KundenSuite` oder `PERSON: Max Mustermann` eingegeben, nur für dieses Projekt gespeichert und mit `force_masks` pro API-Aufruf übertragen. Die globale Velum-Regelliste bleibt unverändert. Beim Erstellen eines Projektstands werden abgewählte Treffer als typgebundene Projekt-Whitelist außerhalb des Quellordners gespeichert und bei späteren Läufen nicht erneut angeboten. Die Whitelist lässt sich im Startformular bearbeiten. Eine neue Projektbearbeitung übernimmt die bestätigten Zuordnungen des letzten Projektstands, sodass Platzhalter konsistent bleiben. Nach einem Lauf mit Kopien bleibt der Klartext-Quellordner erhalten und kann nach Dateiänderungen erneut bearbeitet werden. Nach dem Ersetzen vor Ort werden unveränderte bereits maskierte Dateien in den nächsten Stand übernommen; neue oder geänderte Dateien werden analysiert. Ein neuer Projektstand enthält auch die übernommenen Dateien für die Rückübersetzung.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ export type BatchItem = { key: string; text: string };
|
|||||||
export type BatchSegment = BatchItem & { start: number; end: number };
|
export type BatchSegment = BatchItem & { start: number; end: number };
|
||||||
export type TextBatch = { text: string; segments: BatchSegment[] };
|
export type TextBatch = { text: string; segments: BatchSegment[] };
|
||||||
|
|
||||||
|
/** Filename separators look like spaces to NER, except inside known literal terms. */
|
||||||
|
export function filenameScanText(stem: string, knownTerms: Iterable<string>): string {
|
||||||
|
const protectedOffsets = new Set<number>();
|
||||||
|
for (const term of knownTerms) {
|
||||||
|
if (!term.includes("_")) continue;
|
||||||
|
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const pattern = new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, "giu");
|
||||||
|
for (const match of stem.matchAll(pattern)) {
|
||||||
|
for (let offset = match.index; offset < match.index + match[0].length; offset++) protectedOffsets.add(offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stem.replace(/_/g, (_character, offset: number) => protectedOffsets.has(offset) ? "_" : " ");
|
||||||
|
}
|
||||||
|
|
||||||
/** Keep file boundaries while reducing many small text/path API calls to a few batches. */
|
/** Keep file boundaries while reducing many small text/path API calls to a few batches. */
|
||||||
export function makeTextBatches(items: BatchItem[], limit = MAX_BATCH_CODEPOINTS): TextBatch[] {
|
export function makeTextBatches(items: BatchItem[], limit = MAX_BATCH_CODEPOINTS): TextBatch[] {
|
||||||
const batches: TextBatch[] = [];
|
const batches: TextBatch[] = [];
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export async function reviseProjectRun(args: {
|
|||||||
const { directory, run } = created;
|
const { directory, run } = created;
|
||||||
run.origin = previous.origin;
|
run.origin = previous.origin;
|
||||||
run.revisedFrom = previous.id;
|
run.revisedFrom = previous.id;
|
||||||
|
run.commentAuthorCoverage = previous.commentAuthorCoverage;
|
||||||
run.mapping = plan.mapping;
|
run.mapping = plan.mapping;
|
||||||
run.matchModes = Object.fromEntries(Object.entries(previous.matchModes ?? {})
|
run.matchModes = Object.fromEntries(Object.entries(previous.matchModes ?? {})
|
||||||
.filter(([key]) => key in plan.mapping));
|
.filter(([key]) => key in plan.mapping));
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export type ProjectRun = {
|
|||||||
sourceInPlace?: boolean;
|
sourceInPlace?: boolean;
|
||||||
/** Previous immutable snapshot when a project run was revised after creation. */
|
/** Previous immutable snapshot when a project run was revised after creation. */
|
||||||
revisedFrom?: string;
|
revisedFrom?: string;
|
||||||
|
/** Word comment authors were reviewed with the explicit author recognizer. */
|
||||||
|
commentAuthorCoverage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]);
|
const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "./lib/velum-project";
|
} from "./lib/velum-project";
|
||||||
import type { ProjectFile, ProjectRule, ProjectWhitelistEntry, RunFile } from "./lib/velum-project";
|
import type { ProjectFile, ProjectRule, ProjectWhitelistEntry, RunFile } from "./lib/velum-project";
|
||||||
import {
|
import {
|
||||||
canonicalPerson, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople, spansBySegment,
|
canonicalPerson, filenameScanText, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople, spansBySegment,
|
||||||
withoutWhitelistedHits, withoutWhitelistedRules,
|
withoutWhitelistedHits, withoutWhitelistedRules,
|
||||||
} from "./lib/project-review";
|
} from "./lib/project-review";
|
||||||
|
|
||||||
@@ -117,7 +117,9 @@ async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[],
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const data = await fs.readFile(file.absPath);
|
const data = await fs.readFile(file.absPath);
|
||||||
const hash = digest(data);
|
const hash = digest(data);
|
||||||
if (previousDirectory && previousOutputs.has(file.relPath)
|
const recheckCommentAuthors = entityTypes.includes("PERSON") && file.kind === "document"
|
||||||
|
&& extname(file.relPath).toLowerCase() === ".docx" && !latest?.commentAuthorCoverage;
|
||||||
|
if (!recheckCommentAuthors && previousDirectory && previousOutputs.has(file.relPath)
|
||||||
&& await matchesPreviousOutput(previousDirectory, file.relPath, hash)) {
|
&& await matchesPreviousOutput(previousDirectory, file.relPath, hash)) {
|
||||||
retained.push({ file, hash });
|
retained.push({ file, hash });
|
||||||
continue;
|
continue;
|
||||||
@@ -151,8 +153,14 @@ async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[],
|
|||||||
mapping = result.mapping;
|
mapping = result.mapping;
|
||||||
addHits(draft, documentHits(draft.file, result.hits));
|
addHits(draft, documentHits(draft.file, result.hits));
|
||||||
}
|
}
|
||||||
const paths = pathSegments(drafts.map((draft) => draft.file));
|
// Recheck names even when an in-place file's contents are unchanged. An
|
||||||
const pathBatches = makeTextBatches(paths.map((path) => ({ key: path.key, text: path.value })));
|
// earlier run may have masked the content but left the filename clear.
|
||||||
|
const paths = pathSegments(files);
|
||||||
|
const knownTerms = [...Object.keys(mapping), ...Object.values(mapping).map((entry) => entry.original),
|
||||||
|
...withoutWhitelistedRules(rules, whitelist).map((rule) => rule.value)];
|
||||||
|
const pathBatches = makeTextBatches(paths.map((path) => ({
|
||||||
|
key: path.key, text: filenameScanText(path.value, knownTerms),
|
||||||
|
})));
|
||||||
const byPath = new Map(paths.map((path) => [path.key, path]));
|
const byPath = new Map(paths.map((path) => [path.key, path]));
|
||||||
for (const [index, batch] of pathBatches.entries()) {
|
for (const [index, batch] of pathBatches.entries()) {
|
||||||
onProgress(`Dateinamen analysieren: Paket ${index + 1}/${pathBatches.length}`);
|
onProgress(`Dateinamen analysieren: Paket ${index + 1}/${pathBatches.length}`);
|
||||||
@@ -161,7 +169,7 @@ async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[],
|
|||||||
const spans = spansBySegment(batch, result.spans);
|
const spans = spansBySegment(batch, result.spans);
|
||||||
for (const segment of batch.segments) {
|
for (const segment of batch.segments) {
|
||||||
const path = byPath.get(segment.key)!;
|
const path = byPath.get(segment.key)!;
|
||||||
path.hits = withoutWhitelistedHits(toUtf16Offsets(path.value, spans.get(path.key) ?? []).map((span, hitIndex) => ({
|
path.hits = withoutWhitelistedHits(toUtf16Offsets(segment.text, spans.get(path.key) ?? []).map((span, hitIndex) => ({
|
||||||
...span, id: `${path.key}:path:${hitIndex}`, file: path.key,
|
...span, id: `${path.key}:path:${hitIndex}`, file: path.key,
|
||||||
source: "Datei- oder Ordnername", kind: "path" as const,
|
source: "Datei- oder Ordnername", kind: "path" as const,
|
||||||
context: path.value,
|
context: path.value,
|
||||||
@@ -263,6 +271,7 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje
|
|||||||
selectedPeople.has(source) && availableTargets.has(canonicalPerson(target, personMerges))));
|
selectedPeople.has(source) && availableTargets.has(canonicalPerson(target, personMerges))));
|
||||||
run.mapping = mappingForSelection(props.preview.mapping, props.preview.previousMapping,
|
run.mapping = mappingForSelection(props.preview.mapping, props.preview.previousMapping,
|
||||||
acceptedHits, activeMerges);
|
acceptedHits, activeMerges);
|
||||||
|
run.commentAuthorCoverage = props.preview.entityTypes.includes("PERSON");
|
||||||
run.matchModes = props.preview.matchModes;
|
run.matchModes = props.preview.matchModes;
|
||||||
run.sourceInPlace = props.preview.sourceInPlace || inPlace;
|
run.sourceInPlace = props.preview.sourceInPlace || inPlace;
|
||||||
const usedPaths = new Set<string>();
|
const usedPaths = new Set<string>();
|
||||||
@@ -270,13 +279,16 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje
|
|||||||
for (const retained of props.preview.retained) {
|
for (const retained of props.preview.retained) {
|
||||||
const data = await fs.readFile(retained.file.absPath);
|
const data = await fs.readFile(retained.file.absPath);
|
||||||
if (digest(data) !== retained.hash) throw new Error(`Datei wurde seit der Vorschau geändert: ${retained.file.relPath}`);
|
if (digest(data) !== retained.hash) throw new Error(`Datei wurde seit der Vorschau geändert: ${retained.file.relPath}`);
|
||||||
const relPath = safeRelativePath(retained.file.relPath);
|
const relPath = outputPath(retained.file.relPath, props.preview.paths, accepted,
|
||||||
|
retained.file.kind === "document", activeMerges);
|
||||||
if (usedPaths.has(relPath)) throw new Error(`Zwei Dateien hätten denselben Ausgabenamen: ${relPath}`);
|
if (usedPaths.has(relPath)) throw new Error(`Zwei Dateien hätten denselben Ausgabenamen: ${relPath}`);
|
||||||
usedPaths.add(relPath);
|
usedPaths.add(relPath);
|
||||||
const target = resolveWithin(join(directory, "files"), relPath);
|
const target = resolveWithin(join(directory, "files"), relPath);
|
||||||
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
||||||
await fs.writeFile(target, data, { mode: 0o600 });
|
await fs.writeFile(target, data, { mode: 0o600 });
|
||||||
run.files.push({ source: relPath, output: relPath, kind: retained.file.kind });
|
const file: RunFile = { source: retained.file.relPath, output: relPath, kind: retained.file.kind };
|
||||||
|
run.files.push(file);
|
||||||
|
if (file.source !== file.output) replacements.push(file);
|
||||||
}
|
}
|
||||||
for (const draft of props.preview.drafts) {
|
for (const draft of props.preview.drafts) {
|
||||||
const data = await fs.readFile(draft.file.absPath);
|
const data = await fs.readFile(draft.file.absPath);
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ export async function requireProjectCapabilities(): Promise<void> {
|
|||||||
const response = await fetchWithAuth(apiUrl("/api/project-capabilities"));
|
const response = await fetchWithAuth(apiUrl("/api/project-capabilities"));
|
||||||
if (!response.ok) throw new Error("Die Velum-API muss für die Projektfunktionen aktualisiert werden.");
|
if (!response.ok) throw new Error("Die Velum-API muss für die Projektfunktionen aktualisiert werden.");
|
||||||
const capabilities = await parseJsonResponse<Record<string, boolean>>(response);
|
const capabilities = await parseJsonResponse<Record<string, boolean>>(response);
|
||||||
if (!capabilities.request_force_masks || !capabilities.document_hit_review || !capabilities.document_restoration) {
|
if (!capabilities.request_force_masks || !capabilities.document_hit_review
|
||||||
|
|| !capabilities.document_restoration || !capabilities.comment_author_review) {
|
||||||
throw new Error("Die Velum-API unterstützt die Projektfunktionen noch nicht vollständig.");
|
throw new Error("Die Velum-API unterstützt die Projektfunktionen noch nicht vollständig.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { expect, it } from "bun:test";
|
import { expect, it } from "bun:test";
|
||||||
import {
|
import {
|
||||||
canonicalPerson, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople,
|
canonicalPerson, filenameScanText, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople,
|
||||||
spansBySegment, withoutWhitelistedHits, withoutWhitelistedRules,
|
spansBySegment, withoutWhitelistedHits, withoutWhitelistedRules,
|
||||||
} from "../src/lib/project-review";
|
} from "../src/lib/project-review";
|
||||||
|
|
||||||
|
it("liest Unterstriche in Namen als Leerzeichen und bewahrt bekannte Codebegriffe", () => {
|
||||||
|
expect(filenameScanText("Max_Mustermann_Abgabe", []))
|
||||||
|
.toBe("Max Mustermann Abgabe");
|
||||||
|
expect(filenameScanText("Max_Mustermann_KundenSuite_X", ["KundenSuite_X"]))
|
||||||
|
.toBe("Max Mustermann KundenSuite_X");
|
||||||
|
expect(filenameScanText("😀_Max_Mustermann", []))
|
||||||
|
.toBe("😀 Max Mustermann");
|
||||||
|
expect(filenameScanText("PERSON_01_Max_Mustermann", ["PERSON_01"]))
|
||||||
|
.toBe("PERSON_01 Max Mustermann");
|
||||||
|
});
|
||||||
|
|
||||||
it("ordnet gebündelte API-Treffer nach Unicode-Positionen den richtigen Dateien zu", () => {
|
it("ordnet gebündelte API-Treffer nach Unicode-Positionen den richtigen Dateien zu", () => {
|
||||||
const [batch] = makeTextBatches([
|
const [batch] = makeTextBatches([
|
||||||
{ key: "eins.txt", text: "😀 Max" },
|
{ key: "eins.txt", text: "😀 Max" },
|
||||||
|
|||||||
Reference in New Issue
Block a user