Initial commit: Velum Raycast extension
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>
This commit is contained in:
304
src/reply.ts
Normal file
304
src/reply.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { LocalStorage } from "@raycast/api";
|
||||
import type { PlaceholderMapping } from "./types";
|
||||
|
||||
const REPLY_DEFAULTS_KEY = "velum.reply.defaults.v1";
|
||||
|
||||
export type ReplyDefaults = {
|
||||
greeting: string;
|
||||
signOff: string;
|
||||
model: string;
|
||||
creativity: string;
|
||||
disclosure: boolean;
|
||||
};
|
||||
|
||||
export async function loadReplyDefaults(): Promise<Partial<ReplyDefaults>> {
|
||||
const raw = await LocalStorage.getItem<string>(REPLY_DEFAULTS_KEY);
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw) as Partial<ReplyDefaults>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveReplyDefaults(values: ReplyDefaults): Promise<void> {
|
||||
await LocalStorage.setItem(REPLY_DEFAULTS_KEY, JSON.stringify(values));
|
||||
}
|
||||
|
||||
export const GREETING_OPTIONS: Array<{ value: string; title: string }> = [
|
||||
{ value: "Lieber", title: "Lieber" },
|
||||
{ value: "Liebe", title: "Liebe" },
|
||||
{ value: "Hallo", title: "Hallo" },
|
||||
{ value: "Hi", title: "Hi" },
|
||||
{ value: "Sehr geehrter", title: "Sehr geehrter" },
|
||||
{ value: "Sehr geehrte", title: "Sehr geehrte" },
|
||||
];
|
||||
|
||||
export const SIGN_OFF_OPTIONS: Array<{ value: string; title: string }> = [
|
||||
{ value: "Alles Liebe,", title: "Alles Liebe," },
|
||||
{ value: "Liebe Grüße,", title: "Liebe Grüße," },
|
||||
{ value: "Viele Grüße,", title: "Viele Grüße," },
|
||||
{ value: "Beste Grüße,", title: "Beste Grüße," },
|
||||
{ value: "Mit freundlichen Grüßen,", title: "Mit freundlichen Grüßen," },
|
||||
];
|
||||
|
||||
export function listPersonPlaceholdersInText(
|
||||
pseudonymizedText: string,
|
||||
mapping: PlaceholderMapping,
|
||||
): string[] {
|
||||
return listPersonPlaceholders(mapping).filter((placeholder) =>
|
||||
pseudonymizedText.includes(placeholder),
|
||||
);
|
||||
}
|
||||
|
||||
export function guessRecipientPlaceholder(
|
||||
pseudonymizedText: string,
|
||||
mapping: PlaceholderMapping,
|
||||
): string | null {
|
||||
const persons = listPersonPlaceholdersInText(pseudonymizedText, mapping);
|
||||
if (persons.length === 0) return null;
|
||||
|
||||
const fromMatch = pseudonymizedText.match(
|
||||
/(?:^|\n)\s*(?:Von|From|Absender)\s*:[^\n]*?(<PERSON_\d+>)/i,
|
||||
);
|
||||
if (fromMatch) return fromMatch[1];
|
||||
|
||||
const signOffMatch = pseudonymizedText.match(
|
||||
/(?:Liebe Grüße|Liebe Gruesse|Beste Grüße|Beste Gruesse|Mit freundlichen Grüßen|Mit freundlichen Gruessen|Viele Grüße|Viele Gruesse|Alles Liebe|Herzliche Grüße|Herzliche Gruesse|MfG|LG|VG|Cheers|Best regards|Kind regards)[\s,!.\n]+(<PERSON_\d+>)/i,
|
||||
);
|
||||
if (signOffMatch) return signOffMatch[1];
|
||||
|
||||
const greetingMatch = pseudonymizedText.match(
|
||||
/(?:Lieber|Liebe|Hallo|Hi|Hey|Sehr geehrter|Sehr geehrte|Guten Tag)\s+(?:Herr |Frau )?(<PERSON_\d+>)/i,
|
||||
);
|
||||
const greetingPlaceholder = greetingMatch ? greetingMatch[1] : null;
|
||||
|
||||
for (const placeholder of persons) {
|
||||
if (placeholder === greetingPlaceholder) continue;
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
return persons[0];
|
||||
}
|
||||
|
||||
export function listPersonPlaceholders(mapping: PlaceholderMapping): string[] {
|
||||
return Object.entries(mapping)
|
||||
.filter(([, entry]) => entry.type.toUpperCase() === "PERSON")
|
||||
.map(([placeholder]) => placeholder)
|
||||
.sort((a, b) => {
|
||||
const numA = Number.parseInt(a.replace(/\D/g, ""), 10);
|
||||
const numB = Number.parseInt(b.replace(/\D/g, ""), 10);
|
||||
if (Number.isFinite(numA) && Number.isFinite(numB)) {
|
||||
return numA - numB;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
export function extractPersonPlaceholdersFromText(
|
||||
pseudonymizedText: string,
|
||||
): string[] {
|
||||
const matches = pseudonymizedText.match(/<PERSON_\d+>/g) ?? [];
|
||||
const unique = Array.from(new Set(matches));
|
||||
return unique.sort((a, b) => {
|
||||
const numA = Number.parseInt(a.replace(/\D/g, ""), 10);
|
||||
const numB = Number.parseInt(b.replace(/\D/g, ""), 10);
|
||||
return numA - numB;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildReplyPrompt(options: {
|
||||
pseudonymizedText: string;
|
||||
greeting: string;
|
||||
instructions?: string;
|
||||
}): string {
|
||||
const extra = options.instructions?.trim()
|
||||
? `\n\nZusätzliche Anweisungen des Nutzers:\n${options.instructions.trim()}`
|
||||
: "";
|
||||
|
||||
const availablePersons = extractPersonPlaceholdersFromText(
|
||||
options.pseudonymizedText,
|
||||
);
|
||||
const allowlistText =
|
||||
availablePersons.length > 0
|
||||
? `Erlaubte PERSON-Platzhalter (du darfst NUR genau einen aus dieser Liste verwenden, zeichengetreu inkl. spitzer Klammern): ${availablePersons.join(", ")}.`
|
||||
: "In der Konversation kommt kein PERSON-Platzhalter vor. Verzichte in diesem Fall ausnahmsweise auf den Namen in der Anrede und schreibe nur die Anrede ohne Namen, gefolgt von einem Komma.";
|
||||
|
||||
return [
|
||||
"Du erhältst eine pseudonymisierte Email-Konversation (oder eine einzelne Email) und sollst eine Antwort verfassen.",
|
||||
"",
|
||||
"Personenbezogene Daten wurden bereits durch Platzhalter wie <PERSON_1>, <EMAIL_2>, <ORG_3>, <PHONE_4>, <ADDRESS_5> ersetzt.",
|
||||
"STRENGE REGEL: Gib jeden Platzhalter zeichengetreu (inklusive spitzer Klammern, Großbuchstaben und Unterstrich + Nummer) zurück. Erfinde keine neuen Platzhalter.",
|
||||
"Du darfst Platzhalter NIEMALS auflösen, raten, übersetzen oder mit erfundenen Namen ersetzen.",
|
||||
"",
|
||||
"WICHTIG — wer ist der Empfänger der Antwort?",
|
||||
"Die zu beantwortende Email ist die OBERSTE/NEUESTE im Text (Email-Clients zeigen neueste oben).",
|
||||
"In dieser obersten Email gilt:",
|
||||
"- Direkt am Anfang steht meist eine Anrede wie „Lieber <PERSON_X>,“ oder „Hallo <PERSON_X>,“. Diese Person ist der EMPFÄNGER der EINGEGANGENEN Email — also der NUTZER selbst, für den du die Antwort verfasst. Verwende DIESEN Platzhalter NICHT als Adressaten deiner Antwort.",
|
||||
"- Am Ende der obersten Email (vor möglichen älteren, eingerückten/zitierten Mails) steht meist eine Grußformel („LG <PERSON_Y>“, „Liebe Grüße <PERSON_Y>“, „Mit freundlichen Grüßen <PERSON_Y>“, o. ä.). Diese Person <PERSON_Y> ist der SENDER der eingegangenen Email — und damit der EMPFÄNGER deiner Antwort.",
|
||||
"Beispiel: Eingang lautet „Lieber <PERSON_3>, … LG <PERSON_7>“. Dann ist <PERSON_3> der Nutzer (NICHT verwenden), und du adressierst die Antwort an <PERSON_7>.",
|
||||
"",
|
||||
"Aufgabe: Verfasse die Antwort auf Deutsch.",
|
||||
"",
|
||||
"Format der Antwort — exakt einhalten:",
|
||||
`1. Erste Zeile: Begrüßung im Format „${options.greeting} <PERSON_NUMMER>,“ — wähle den Platzhalter des SENDERS der obersten Email (siehe oben). Wähle NICHT den Platzhalter aus der Anrede der eingegangenen Email.`,
|
||||
` ${allowlistText}`,
|
||||
" ABSOLUT VERBOTEN: Schreibe niemals `<PERSON_N>`, `<PERSON_X>`, `<PERSON_XX>`, `<PERSON_x>` oder ähnliche Schema-Platzhalter mit Buchstaben statt Ziffern. Nur konkrete Werte aus der Allowlist sind erlaubt.",
|
||||
"2. Eine Leerzeile.",
|
||||
"3. Den eigentlichen Antworttext.",
|
||||
"WICHTIG: Schreibe KEINE Signatur, KEINE Grußformel und KEINEN Absendernamen am Ende — die Grußformel und der Absender werden außerhalb der KI angefügt.",
|
||||
extra,
|
||||
"",
|
||||
"--- Email-Konversation (pseudonymisiert) ---",
|
||||
options.pseudonymizedText,
|
||||
"--- Ende der Konversation ---",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function composeReplyEmail(options: {
|
||||
body: string;
|
||||
signOff: string;
|
||||
userFullName: string;
|
||||
disclosureBlock?: string;
|
||||
}): string {
|
||||
const trimmedBody = options.body.trim();
|
||||
const signature = `${options.signOff}\n${options.userFullName.trim()}`;
|
||||
const tail = options.disclosureBlock?.trim()
|
||||
? `\n\n${options.disclosureBlock.trim()}`
|
||||
: "";
|
||||
return `${trimmedBody}\n\n${signature}${tail}`;
|
||||
}
|
||||
|
||||
export function extractAllPlaceholdersFromText(
|
||||
pseudonymizedText: string,
|
||||
): string[] {
|
||||
const matches = pseudonymizedText.match(/<[A-Z]+_\d+>/g) ?? [];
|
||||
return Array.from(new Set(matches));
|
||||
}
|
||||
|
||||
export type DisclosureContent = {
|
||||
text: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
PERSON: "Personen",
|
||||
ORT: "Orte",
|
||||
LOCATION: "Orte",
|
||||
GPE: "Orte",
|
||||
EMAIL: "E-Mail-Adressen",
|
||||
EMAIL_ADDRESS: "E-Mail-Adressen",
|
||||
PHONE: "Telefonnummern",
|
||||
PHONE_NUMBER: "Telefonnummern",
|
||||
ADDRESS: "Adressen",
|
||||
ORG: "Organisationen",
|
||||
ORGANIZATION: "Organisationen",
|
||||
DATE: "Datumsangaben",
|
||||
TIME: "Zeitangaben",
|
||||
IBAN: "IBANs",
|
||||
IBAN_CODE: "IBANs",
|
||||
URL: "URLs",
|
||||
CREDIT_CARD: "Kreditkartennummern",
|
||||
ID: "IDs",
|
||||
MISC: "Sonstiges",
|
||||
};
|
||||
|
||||
function labelForType(type: string): string {
|
||||
const upper = type.toUpperCase();
|
||||
return TYPE_LABELS[upper] ?? upper;
|
||||
}
|
||||
|
||||
export function buildDisclosureContent(
|
||||
pseudonymizedText: string,
|
||||
mapping: PlaceholderMapping,
|
||||
): DisclosureContent | null {
|
||||
const entries = Object.entries(mapping)
|
||||
.filter(([key]) => {
|
||||
const stripped = key.replace(/[<>]/g, "");
|
||||
return (
|
||||
pseudonymizedText.includes(key) ||
|
||||
pseudonymizedText.includes(`<${stripped}>`) ||
|
||||
pseudonymizedText.includes(stripped)
|
||||
);
|
||||
})
|
||||
.map(([key, entry]) => ({
|
||||
original: entry.original,
|
||||
placeholder: key.replace(/[<>]/g, ""),
|
||||
type: entry.type,
|
||||
}))
|
||||
.sort((a, b) => a.placeholder.localeCompare(b.placeholder));
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const groups = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
const label = labelForType(entry.type);
|
||||
const list = groups.get(label) ?? [];
|
||||
list.push(entry);
|
||||
groups.set(label, list);
|
||||
}
|
||||
const groupedEntries = Array.from(groups.entries()).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
|
||||
const heading = "Dieses Email wurde KI generiert.";
|
||||
const subheading =
|
||||
"Folgende Texte wurden vor der Übermittlung lokal maskiert:";
|
||||
|
||||
const text = [
|
||||
"---",
|
||||
heading,
|
||||
subheading,
|
||||
...groupedEntries.map(
|
||||
([label, list]) =>
|
||||
`${label}: ${list.map((e) => `${e.original} (${e.placeholder})`).join(", ")}`,
|
||||
),
|
||||
].join("\n");
|
||||
|
||||
const html = [
|
||||
"<hr>",
|
||||
`<p>${escapeHtml(heading)}<br>${escapeHtml(subheading)}</p>`,
|
||||
"<p>",
|
||||
groupedEntries
|
||||
.map(
|
||||
([label, list]) =>
|
||||
`<strong>${escapeHtml(label)}:</strong> ${list
|
||||
.map(
|
||||
(e) =>
|
||||
`${escapeHtml(e.original)} (<span style="font-family: monospace;">${escapeHtml(e.placeholder)}</span>)`,
|
||||
)
|
||||
.join(", ")}`,
|
||||
)
|
||||
.join("<br>"),
|
||||
"</p>",
|
||||
].join("\n");
|
||||
|
||||
return { text, html };
|
||||
}
|
||||
|
||||
export function plainTextToHtmlEmail(plainText: string): string {
|
||||
const escaped = plainText
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const paragraphs = escaped.split(/\n\n+/);
|
||||
return paragraphs
|
||||
.map((paragraph) => {
|
||||
if (/^\s*---\s*$/.test(paragraph)) {
|
||||
return "<hr>";
|
||||
}
|
||||
if (/^---\n/.test(paragraph)) {
|
||||
const rest = paragraph.replace(/^---\n/, "");
|
||||
return `<hr>\n<p>${rest.replace(/\n/g, "<br>")}</p>`;
|
||||
}
|
||||
return `<p>${paragraph.replace(/\n/g, "<br>")}</p>`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user