feat: add project-status command for steering-committee updates
Turn raw project notes into a structured weekly Steering-Update with
fixed sections (Status traffic light, Fortschritt, Top-Risiken,
Entscheidung, Nächster Schritt, GF-Summary). Calendar week and today's
date are injected automatically so KW [Nummer] resolves correctly.
The optional trigger phrases from the prompt ("Mach mir auch eine
GF-Mail dazu.", "Wo sind blinde Flecken?", "Kürzer.") flow through the
existing "Zusätzliche Anweisungen" textarea, so the user can append
them per call without leaving the form. Signature name comes from
userFullName preference, falling back to "Raphael".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
- `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.
|
- `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.
|
- `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
|
### Pseudonymisieren
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,12 @@
|
|||||||
"description": "Strukturierte Daten (JSON oder Tabelle) aus Freitext gemäß einem Schema extrahieren — pseudonymisiert.",
|
"description": "Strukturierte Daten (JSON oder Tabelle) aus Freitext gemäß einem Schema extrahieren — pseudonymisiert.",
|
||||||
"mode": "view"
|
"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",
|
"name": "pseudonymize-text",
|
||||||
"title": "Text pseudonymisieren",
|
"title": "Text pseudonymisieren",
|
||||||
|
|||||||
369
src/project-status.tsx
Normal file
369
src/project-status.tsx
Normal file
@@ -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 <PERSON_1>, <ORG_2>, <DATE_3>, <EMAIL_4> 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<VelumSession[]>([]);
|
||||||
|
const [activeSessionId, setActiveSessionId] = useState<string>();
|
||||||
|
const [entityTypes, setEntityTypes] = useState<EntityType[]>([]);
|
||||||
|
const [selectedEntityTypes, setSelectedEntityTypes] = useState<string[]>([]);
|
||||||
|
const [model, setModel] = useState<string>(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(
|
||||||
|
<PseudonymizationConfirm
|
||||||
|
session={updatedSession}
|
||||||
|
pseudonymizedText={pseudoResult.pseudonymized_text}
|
||||||
|
model={values.model}
|
||||||
|
creativity={values.creativity}
|
||||||
|
instructions={values.instructions}
|
||||||
|
buildPrompt={buildPrompt}
|
||||||
|
labels={{
|
||||||
|
pageHeading: "Steering-Update",
|
||||||
|
runningLabel: "KI erstellt Steering-Update …",
|
||||||
|
failureTitle: "Steering-Update fehlgeschlagen",
|
||||||
|
copyTitle: "Steering-Update kopieren",
|
||||||
|
pasteTitle: "Steering-Update einfügen",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.style = Toast.Style.Failure;
|
||||||
|
toast.title = "Pseudonymisierung fehlgeschlagen";
|
||||||
|
toast.message = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
isLoading={isLoading}
|
||||||
|
actions={
|
||||||
|
<ActionPanel>
|
||||||
|
<Action.SubmitForm
|
||||||
|
title="Steering-Update erstellen"
|
||||||
|
icon={Icon.LineChart}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
<Action
|
||||||
|
title="Markierten Text übernehmen"
|
||||||
|
icon={Icon.TextSelection}
|
||||||
|
onAction={loadSelectedText}
|
||||||
|
/>
|
||||||
|
<Action
|
||||||
|
title="Zwischenablage übernehmen"
|
||||||
|
icon={Icon.Clipboard}
|
||||||
|
onAction={loadClipboardText}
|
||||||
|
/>
|
||||||
|
</ActionPanel>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.TextArea
|
||||||
|
id="text"
|
||||||
|
title="Rohnotizen"
|
||||||
|
value={text}
|
||||||
|
onChange={setText}
|
||||||
|
placeholder="Statusmeeting-Stichpunkte, Email-Auszüge, Risiken, Zahlen, Entscheidungen …"
|
||||||
|
/>
|
||||||
|
<Form.Dropdown
|
||||||
|
id="sessionId"
|
||||||
|
title="Sitzung"
|
||||||
|
defaultValue={defaultSessionId}
|
||||||
|
>
|
||||||
|
<Form.Dropdown.Item
|
||||||
|
value={NEW_SESSION_ID}
|
||||||
|
title="Neue Sitzung"
|
||||||
|
icon={Icon.Plus}
|
||||||
|
/>
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<Form.Dropdown.Item
|
||||||
|
key={session.id}
|
||||||
|
value={session.id}
|
||||||
|
title={session.name}
|
||||||
|
icon={
|
||||||
|
session.id === activeSessionId ? Icon.CheckCircle : Icon.Circle
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Form.Dropdown>
|
||||||
|
{entityTypes.length > 0 ? (
|
||||||
|
<Form.TagPicker
|
||||||
|
id="entityTypes"
|
||||||
|
title="Entitätstypen"
|
||||||
|
value={selectedEntityTypes}
|
||||||
|
onChange={setSelectedEntityTypes}
|
||||||
|
>
|
||||||
|
{entityTypes.map((entityType) => (
|
||||||
|
<Form.TagPicker.Item
|
||||||
|
key={entityType}
|
||||||
|
value={entityType}
|
||||||
|
title={entityType}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Form.TagPicker>
|
||||||
|
) : null}
|
||||||
|
<Form.Dropdown
|
||||||
|
id="model"
|
||||||
|
title="KI-Modell"
|
||||||
|
value={model}
|
||||||
|
onChange={setModel}
|
||||||
|
>
|
||||||
|
{MODEL_OPTIONS.map((option) => (
|
||||||
|
<Form.Dropdown.Item
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
title={option.title}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Form.Dropdown>
|
||||||
|
<Form.Dropdown id="creativity" title="Kreativität" defaultValue="low">
|
||||||
|
{CREATIVITY_OPTIONS.map((value) => (
|
||||||
|
<Form.Dropdown.Item key={value} value={value} title={value} />
|
||||||
|
))}
|
||||||
|
</Form.Dropdown>
|
||||||
|
<Form.TextArea
|
||||||
|
id="instructions"
|
||||||
|
title="Zusätzliche Anweisungen"
|
||||||
|
placeholder="Optional. Triggerphrasen: „Mach mir auch eine GF-Mail dazu.“ · „Wo sind blinde Flecken?“ · „Kürzer.“"
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user