diff --git a/README.md b/README.md index 8d764cc..93d55d9 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Jeder AI-Befehl pseudonymisiert die Eingabe via Velum, zeigt einen Confirm-Schri - `Briefing aus Notizen` — strukturiertes Briefing (Kontext, Teilnehmer, Entscheidungen, Action Items, Offene Punkte) aus Notizen, Stichpunkten oder Meeting-Transkripten. - `Action Items extrahieren` — Markdown-Tabelle (Aufgabe / Verantwortlich / Deadline / Status) aus Transkripten, Threads oder Notizen. - `Strukturierte Daten extrahieren` — JSON oder Markdown-Tabelle aus Freitext, gemäß einem frei beschriebenen Schema. +- `Projektstatusbericht erstellen` — Steering-Update für den Lenkungsausschuss aus Rohnotizen: Status (Ampel), Fortschritt, Top-Risiken, Entscheidung, nächste Schritte, GF-Summary. Triggerphrasen in den zusätzlichen Anweisungen: „Mach mir auch eine GF-Mail dazu.", „Wo sind blinde Flecken?", „Kürzer." ### Pseudonymisieren diff --git a/package.json b/package.json index 5c96f6d..d880918 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,12 @@ "description": "Strukturierte Daten (JSON oder Tabelle) aus Freitext gemäß einem Schema extrahieren — pseudonymisiert.", "mode": "view" }, + { + "name": "project-status", + "title": "Projektstatusbericht erstellen", + "description": "Aus Rohnotizen ein Steering-Update für den Lenkungsausschuss erstellen — pseudonymisiert.", + "mode": "view" + }, { "name": "pseudonymize-text", "title": "Text pseudonymisieren", diff --git a/src/project-status.tsx b/src/project-status.tsx new file mode 100644 index 0000000..58fc9e3 --- /dev/null +++ b/src/project-status.tsx @@ -0,0 +1,369 @@ +import { + Action, + ActionPanel, + Clipboard, + Form, + Icon, + showToast, + Toast, + useNavigation, +} from "@raycast/api"; +import { useEffect, useMemo, useState } from "react"; +import { + type Creativity, + CREATIVITY_OPTIONS, + DEFAULT_MODEL, + getLastUsedModel, + MODEL_OPTIONS, + setLastUsedModel, + STRICT_PLACEHOLDER_RULE, +} from "./ai"; +import { PseudonymizationConfirm } from "./ai-views"; +import { getPreferences } from "./preferences"; +import { getSelectedTextSafely } from "./selection"; +import { + createSession, + getActiveSessionId, + getSession, + listSessions, + setActiveSession, + updateSessionMapping, +} from "./sessions"; +import type { EntityType, VelumSession } from "./types"; +import { NEW_SESSION_ID } from "./ui"; +import { + getEntityTypes, + normalizePseudonymizeResponse, + pseudonymize, +} from "./velum"; + +function getIsoWeek(date: Date): number { + const d = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +} + +function buildProjectStatusPrompt(userFullName: string) { + const signatureName = userFullName.trim() || "Raphael"; + + return function (pseudonymizedText: string, instructions: string): string { + const today = new Date(); + const todayIso = today.toISOString().slice(0, 10); + const week = getIsoWeek(today); + + const extra = instructions.trim() + ? `\n\nZusätzliche Anweisungen des Nutzers:\n${instructions.trim()}` + : ""; + + return [ + `Heute ist ${todayIso} (KW ${week}).`, + "Personenbezogene Daten in den Rohnotizen sind durch Platzhalter wie , , , ersetzt.", + "", + STRICT_PLACEHOLDER_RULE, + "", + "# ROLLE", + "Du bist erfahrener SAP-Projektleiter und unterstützt mich bei der", + "Vorbereitung von Steering-Committee-Updates und Management-Kommunikation.", + "", + "# KONTEXT", + "Ich liefere dir laufend Rohnotizen aus dem Projekt: Stichpunkte aus", + "Statusmeetings, Auszüge aus E-Mails, Risiken, Zahlen, Entscheidungen.", + "Daraus erstelle ich Steering-Updates für den Lenkungsausschuss.", + "", + "# AUFGABE", + "Wenn ich Rohnotizen schicke, erstelle ein Steering-Update in genau", + "folgender Struktur:", + "", + "## Steering-Update · KW [Nummer]", + "", + "## Status: [🔴 rot | 🟡 gelb | 🟢 grün] // immer das Ampel Emoji ausgeben!", + "Begründung in einem Satz.", + "", + "## Fortschritt:", + "2–3 Bullets: Was ist seit dem letzten Update passiert.", + "", + "## Top-Risiken:", + "Maximal 3 Bullets. Jeder Bullet im Format:", + "- [Risiko] — Auswirkung — empfohlene Maßnahme.", + "", + "## Entscheidung:", + "Welche Entscheidung muss der Lenkungsausschuss diese Woche treffen?", + "Formuliere sie als klare Frage mit 2 Optionen.", + "", + "## Nächster Schritt:", + "2–3 konkrete nächste Aktionen mit Verantwortlichkeit (sofern erkennbar).", + "", + "## GF-Summary:", + "Eine einzige Aussage in 1–2 Sätzen — was die Geschäftsführung wissen muss,", + "wenn sie nur diesen Block liest.", + "", + "# REGELN", + "- Antworte ausschließlich in deutscher Geschäftssprache.", + "- Sachlich, knapp, nicht dramatisierend. Keine Floskeln, keine Emojis (außer dem Ampel-Emoji im Status-Block).", + "- Verwende Ampellogik konsequent: rot = gefährdet, gelb = unter Beobachtung, grün = im Plan.", + '- Wenn Informationen fehlen, frage nicht nach — sondern markiere die Stelle mit „[unklar: …]“. Lass keine Stelle leer.', + '- Erfinde keine Zahlen, Termine oder Risiken. Wenn etwas nicht in den Rohnotizen steht, schreibe „[nicht im Input]“.', + '- Schreibe keine Einleitung („Gerne — hier ist dein Update …“) und keinen Abschluss. Nur die Struktur oben, nichts darum herum.', + `- Emails beginnen mit „Liebe“ oder „Lieber“ und enden mit „Alles Liebe,\\n${signatureName}“.`, + "", + "# OPTIONAL — auf Anforderung", + "Wenn der Nutzer in den zusätzlichen Anweisungen eine der folgenden Phrasen schreibt, hänge den entsprechenden Zusatz NACH der oben definierten Struktur an (mit einer Leerzeile getrennt):", + '- „Mach mir auch eine GF-Mail dazu.“ → liefere zusätzlich eine 4–6-Zeilen-Mail an die Geschäftsführung im selben Ton.', + '- „Wo sind blinde Flecken?“ → liste 3–5 kritische Rückfragen, die ein skeptischer Geschäftsführer stellen würde.', + '- „Kürzer.“ → kürze das gesamte Update auf die Hälfte der Länge ohne Substanzverlust (statt anhängen: kürzere Version ersetzt die normale).', + "", + "Antworte ausschließlich in Markdown, ohne Code-Fences (keine ``` Zeilen am Anfang oder Ende).", + extra, + "", + "--- Rohnotizen (pseudonymisiert) ---", + pseudonymizedText, + "--- Ende ---", + ].join("\n"); + }; +} + +type FormValues = { + text: string; + sessionId: string; + entityTypes: string[]; + model: string; + creativity: Creativity; + instructions: string; +}; + +export default function Command() { + const preferences = getPreferences(); + const [text, setText] = useState(""); + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(); + const [entityTypes, setEntityTypes] = useState([]); + const [selectedEntityTypes, setSelectedEntityTypes] = useState([]); + const [model, setModel] = useState(DEFAULT_MODEL); + const [isLoading, setIsLoading] = useState(true); + const { push } = useNavigation(); + + const buildPrompt = useMemo( + () => buildProjectStatusPrompt(preferences.userFullName), + [preferences.userFullName], + ); + + useEffect(() => { + async function load() { + const [loadedSessions, activeId, lastModel] = await Promise.all([ + listSessions(), + getActiveSessionId(), + getLastUsedModel(), + ]); + setSessions(loadedSessions); + setActiveSessionId(activeId); + setModel(lastModel); + + const selection = await getSelectedTextSafely(); + if (selection && selection.trim()) { + setText(selection); + } + + try { + const loadedEntityTypes = await getEntityTypes(); + setEntityTypes(loadedEntityTypes); + setSelectedEntityTypes(loadedEntityTypes); + } catch { + setEntityTypes([]); + } finally { + setIsLoading(false); + } + } + load(); + }, []); + + const defaultSessionId = useMemo( + () => activeSessionId || sessions[0]?.id || NEW_SESSION_ID, + [activeSessionId, sessions], + ); + + async function loadSelectedText() { + const selected = await getSelectedTextSafely(); + if (!selected) { + await showToast({ + style: Toast.Style.Failure, + title: "Kein markierter Text gefunden", + }); + return; + } + setText(selected); + } + + async function loadClipboardText() { + const clipboardText = await Clipboard.readText(); + if (!clipboardText) { + await showToast({ + style: Toast.Style.Failure, + title: "Zwischenablage enthält keinen Text", + }); + return; + } + setText(clipboardText); + } + + async function handleSubmit(values: FormValues) { + const input = values.text.trim(); + if (!input) { + await showToast({ + style: Toast.Style.Failure, + title: "Rohnotizen eingeben", + }); + return; + } + + void setLastUsedModel(values.model); + + const toast = await showToast({ + style: Toast.Style.Animated, + title: "Pseudonymisiere…", + }); + + try { + const session = + values.sessionId === NEW_SESSION_ID + ? await createSession() + : ((await getSession(values.sessionId)) ?? (await createSession())); + await setActiveSession(session.id); + + const rawResult = await pseudonymize( + input, + session.mapping, + values.entityTypes, + ); + const pseudoResult = normalizePseudonymizeResponse(rawResult); + const updatedSession = await updateSessionMapping( + session.id, + pseudoResult.mapping, + pseudoResult.selected_entity_types, + ); + + toast.style = Toast.Style.Success; + toast.title = "Pseudonymisiert"; + toast.message = `${pseudoResult.entity_count} Zuordnungen`; + + push( + , + ); + } catch (error) { + toast.style = Toast.Style.Failure; + toast.title = "Pseudonymisierung fehlgeschlagen"; + toast.message = error instanceof Error ? error.message : String(error); + } + } + + return ( +
+ + + + + } + > + + + + {sessions.map((session) => ( + + ))} + + {entityTypes.length > 0 ? ( + + {entityTypes.map((entityType) => ( + + ))} + + ) : null} + + {MODEL_OPTIONS.map((option) => ( + + ))} + + + {CREATIVITY_OPTIONS.map((value) => ( + + ))} + + + + ); +}