Recheck project filenames and Word commenters

This commit is contained in:
muena
2026-09-25 10:49:21 +02:00
parent d0d6a29ef6
commit c3ef1a6aed
7 changed files with 51 additions and 10 deletions

View File

@@ -9,6 +9,20 @@ export type BatchItem = { key: string; text: string };
export type BatchSegment = BatchItem & { start: number; end: number };
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. */
export function makeTextBatches(items: BatchItem[], limit = MAX_BATCH_CODEPOINTS): TextBatch[] {
const batches: TextBatch[] = [];

View File

@@ -112,6 +112,7 @@ export async function reviseProjectRun(args: {
const { directory, run } = created;
run.origin = previous.origin;
run.revisedFrom = previous.id;
run.commentAuthorCoverage = previous.commentAuthorCoverage;
run.mapping = plan.mapping;
run.matchModes = Object.fromEntries(Object.entries(previous.matchModes ?? {})
.filter(([key]) => key in plan.mapping));

View File

@@ -28,6 +28,8 @@ export type ProjectRun = {
sourceInPlace?: boolean;
/** Previous immutable snapshot when a project run was revised after creation. */
revisedFrom?: string;
/** Word comment authors were reviewed with the explicit author recognizer. */
commentAuthorCoverage?: boolean;
};
const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]);

View File

@@ -14,7 +14,7 @@ import {
} from "./lib/velum-project";
import type { ProjectFile, ProjectRule, ProjectWhitelistEntry, RunFile } from "./lib/velum-project";
import {
canonicalPerson, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople, spansBySegment,
canonicalPerson, filenameScanText, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople, spansBySegment,
withoutWhitelistedHits, withoutWhitelistedRules,
} from "./lib/project-review";
@@ -117,7 +117,9 @@ async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[],
for (const file of files) {
const data = await fs.readFile(file.absPath);
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)) {
retained.push({ file, hash });
continue;
@@ -151,8 +153,14 @@ async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[],
mapping = result.mapping;
addHits(draft, documentHits(draft.file, result.hits));
}
const paths = pathSegments(drafts.map((draft) => draft.file));
const pathBatches = makeTextBatches(paths.map((path) => ({ key: path.key, text: path.value })));
// Recheck names even when an in-place file's contents are unchanged. An
// 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]));
for (const [index, batch] of pathBatches.entries()) {
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);
for (const segment of batch.segments) {
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,
source: "Datei- oder Ordnername", kind: "path" as const,
context: path.value,
@@ -263,6 +271,7 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje
selectedPeople.has(source) && availableTargets.has(canonicalPerson(target, personMerges))));
run.mapping = mappingForSelection(props.preview.mapping, props.preview.previousMapping,
acceptedHits, activeMerges);
run.commentAuthorCoverage = props.preview.entityTypes.includes("PERSON");
run.matchModes = props.preview.matchModes;
run.sourceInPlace = props.preview.sourceInPlace || inPlace;
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) {
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}`);
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}`);
usedPaths.add(relPath);
const target = resolveWithin(join(directory, "files"), relPath);
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
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) {
const data = await fs.readFile(draft.file.absPath);

View File

@@ -142,7 +142,8 @@ export async function requireProjectCapabilities(): Promise<void> {
const response = await fetchWithAuth(apiUrl("/api/project-capabilities"));
if (!response.ok) throw new Error("Die Velum-API muss für die Projektfunktionen aktualisiert werden.");
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.");
}
}