Pseudonymize-and-AI workflow for handling PII-sensitive text via the Velum API and Raycast AI. Commands cover end-to-end email summary and reply, briefing/action-items/structured-data extraction, manual pseudonymize/depseudonymize on selection or clipboard, and session management. Includes Raycast 2.0 Beta workarounds for selection capture and rich-text clipboard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
145 lines
4.0 KiB
TypeScript
145 lines
4.0 KiB
TypeScript
import { fetchWithAuth } from "./auth";
|
|
import { getPreferences } from "./preferences";
|
|
import type {
|
|
DepseudonymizeResponse,
|
|
EntityType,
|
|
PlaceholderMapping,
|
|
PseudonymizeResponse,
|
|
} from "./types";
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
export function normalizePseudonymizeResponse(
|
|
response: PseudonymizeResponse,
|
|
): PseudonymizeResponse {
|
|
const normalizedMapping: PlaceholderMapping = {};
|
|
const strippedKeys = new Set<string>();
|
|
|
|
for (const [key, entry] of Object.entries(response.mapping)) {
|
|
const stripped = key.replace(/[<>]/g, "");
|
|
normalizedMapping[`<${stripped}>`] = entry;
|
|
strippedKeys.add(stripped);
|
|
}
|
|
|
|
let text = response.pseudonymized_text;
|
|
const ordered = Array.from(strippedKeys).sort((a, b) => b.length - a.length);
|
|
for (const stripped of ordered) {
|
|
const bracketed = `<${stripped}>`;
|
|
const bareRegex = new RegExp(
|
|
`(?<![<\\w])${escapeRegExp(stripped)}(?![\\w>])`,
|
|
"g",
|
|
);
|
|
text = text.replace(bareRegex, bracketed);
|
|
}
|
|
|
|
return {
|
|
...response,
|
|
pseudonymized_text: text,
|
|
mapping: normalizedMapping,
|
|
};
|
|
}
|
|
|
|
export function localDepseudonymize(
|
|
text: string,
|
|
mapping: PlaceholderMapping,
|
|
): DepseudonymizeResponse {
|
|
let result = text;
|
|
let replacementsMade = 0;
|
|
|
|
const entries = Object.entries(mapping)
|
|
.map(([key, entry]) => ({
|
|
stripped: key.replace(/[<>]/g, ""),
|
|
entry,
|
|
}))
|
|
.sort((a, b) => b.stripped.length - a.stripped.length);
|
|
|
|
for (const { stripped, entry } of entries) {
|
|
const bracketed = `<${stripped}>`;
|
|
const bracketedRegex = new RegExp(escapeRegExp(bracketed), "g");
|
|
const bracketedMatches = result.match(bracketedRegex);
|
|
if (bracketedMatches) {
|
|
replacementsMade += bracketedMatches.length;
|
|
result = result.replace(bracketedRegex, entry.original);
|
|
}
|
|
|
|
const bareRegex = new RegExp(`\\b${escapeRegExp(stripped)}\\b`, "g");
|
|
const bareMatches = result.match(bareRegex);
|
|
if (bareMatches) {
|
|
replacementsMade += bareMatches.length;
|
|
result = result.replace(bareRegex, entry.original);
|
|
}
|
|
}
|
|
|
|
return { original_text: result, replacements_made: replacementsMade };
|
|
}
|
|
|
|
function apiUrl(path: string): string {
|
|
return `${getPreferences().velumBaseUrl}${path}`;
|
|
}
|
|
|
|
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
if (!response.ok) {
|
|
const details = await response.text().catch(() => response.statusText);
|
|
throw new Error(
|
|
`Velum API request failed: ${response.status} ${response.statusText}: ${details}`,
|
|
);
|
|
}
|
|
if (!contentType.includes("application/json")) {
|
|
const details = await response.text().catch(() => "");
|
|
throw new Error(
|
|
`Velum API returned non-JSON response: ${details.slice(0, 200)}`,
|
|
);
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
export async function getEntityTypes(): Promise<EntityType[]> {
|
|
const response = await fetchWithAuth(apiUrl("/api/entity-types"));
|
|
const payload = await parseJsonResponse<{ entity_types: EntityType[] }>(
|
|
response,
|
|
);
|
|
return payload.entity_types;
|
|
}
|
|
|
|
export async function pseudonymize(
|
|
text: string,
|
|
mapping: PlaceholderMapping,
|
|
entityTypes?: EntityType[],
|
|
): Promise<PseudonymizeResponse> {
|
|
const response = await fetchWithAuth(apiUrl("/api/pseudonymize"), {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
text,
|
|
mapping,
|
|
entity_types:
|
|
entityTypes && entityTypes.length > 0 ? entityTypes : undefined,
|
|
}),
|
|
});
|
|
|
|
return parseJsonResponse<PseudonymizeResponse>(response);
|
|
}
|
|
|
|
export async function depseudonymize(
|
|
text: string,
|
|
mapping: PlaceholderMapping,
|
|
): Promise<DepseudonymizeResponse> {
|
|
const response = await fetchWithAuth(apiUrl("/api/depseudonymize"), {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
text,
|
|
mapping,
|
|
}),
|
|
});
|
|
|
|
return parseJsonResponse<DepseudonymizeResponse>(response);
|
|
}
|