diff --git a/backend/app/core/app_config.py b/backend/app/core/app_config.py index c4c54c6..4802bae 100644 --- a/backend/app/core/app_config.py +++ b/backend/app/core/app_config.py @@ -179,7 +179,7 @@ SYSTEM_PROMPT_SERVICES: dict[str, str] = { def load_all_system_prompts() -> dict: - """Return {service_id: {label, system, user_template}} for all registered services.""" + """Return {service_id: {label, system, user_template, default_system, default_user_template}}.""" result: dict = {} for service_id, label in SYSTEM_PROMPT_SERVICES.items(): config = load_service_config(service_id) @@ -189,6 +189,8 @@ def load_all_system_prompts() -> dict: "label": label, "system": prompts.get("system", defaults["system"]), "user_template": prompts.get("user_template", defaults["user_template"]), + "default_system": defaults["system"], + "default_user_template": defaults["user_template"], } return result diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4242da9..086e68c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -225,6 +225,8 @@ export interface ServiceSystemPrompt { label: string; system: string; user_template: string; + default_system: string; + default_user_template: string; } export type SystemPromptsData = Record; diff --git a/frontend/src/pages/AIAdminSettingsPage.tsx b/frontend/src/pages/AIAdminSettingsPage.tsx index b0112ca..8857ee3 100644 --- a/frontend/src/pages/AIAdminSettingsPage.tsx +++ b/frontend/src/pages/AIAdminSettingsPage.tsx @@ -12,6 +12,53 @@ import { type Provider = "anthropic" | "ollama" | "lmstudio"; type Tab = "general" | "system-prompts"; +// ── Static per-service documentation ────────────────────────────────────────── + +interface ServiceDoc { + description: string; + placeholders: { name: string; required: boolean; description: string }[]; + requiredResponseKeys: string[]; + notes: string[]; +} + +const SERVICE_DOCS: Record = { + doc_service: { + description: + "Extracts structured data from uploaded PDF documents. The system prompt instructs the model to return ONLY a JSON object. The user template is sent once per document with the extracted text injected at {text}.", + placeholders: [ + { + name: "{text}", + required: true, + description: + "The first 50 000 characters of text extracted from the PDF. Must be present in the user template.", + }, + ], + requiredResponseKeys: [ + "title", + "document_type", + "total_amount", + "currency", + "vendor_name", + "customer_name", + "billing_address", + "customer_address", + "invoice_number", + "invoice_date", + "due_date", + "tags", + "line_items", + "suggested_categories", + ], + notes: [ + 'The system prompt should instruct the model to return ONLY a JSON object — no markdown, no code fences, no explanation.', + 'document_type must be one of: invoice, bill, receipt, order, expense, revenue, unknown.', + 'tags and suggested_categories must be JSON arrays of strings.', + 'line_items must be a JSON array of objects, each with keys description and amount.', + 'Missing keys will be stored as null — the service will not error on absent optional fields.', + ], + }, +}; + // ── Shared layout helpers ────────────────────────────────────────────────────── function Section({ title, children }: { title: string; children: React.ReactNode }) { @@ -46,10 +93,135 @@ const inputStyle: React.CSSProperties = { const textareaStyle: React.CSSProperties = { ...inputStyle, fontFamily: "monospace", + fontSize: 13, resize: "vertical", minHeight: 120, }; +// ── Service documentation panel ──────────────────────────────────────────────── + +function ServiceDocsPanel({ serviceId }: { serviceId: string }) { + const [open, setOpen] = useState(false); + const doc = SERVICE_DOCS[serviceId]; + if (!doc) return null; + + return ( +
+ + + {open && ( +
+

{doc.description}

+ + {doc.placeholders.length > 0 && ( + <> +

Template placeholders

+ + + + + + + + + + {doc.placeholders.map((p) => ( + + + + + + ))} + +
PlaceholderRequiredDescription
+ {p.name} + + {p.required ? ( + Yes + ) : ( + "No" + )} + {p.description}
+ + )} + + {doc.requiredResponseKeys.length > 0 && ( + <> +

Expected JSON response keys

+
+ {doc.requiredResponseKeys.map((k) => ( + + {k} + + ))} +
+ + )} + + {doc.notes.length > 0 && ( + <> +

Notes

+
    + {doc.notes.map((n, i) => ( +
  • + {n} +
  • + ))} +
+ + )} +
+ )} +
+ ); +} + +const thStyle: React.CSSProperties = { + padding: "5px 10px", + textAlign: "left", + fontWeight: 600, + fontSize: 12, + borderBottom: "1px solid #ddd", +}; +const tdStyle: React.CSSProperties = { + padding: "5px 10px", + verticalAlign: "top", + borderBottom: "1px solid #eee", +}; +const codeStyle: React.CSSProperties = { + background: "#ececec", + padding: "1px 5px", + borderRadius: 3, + fontFamily: "monospace", + fontSize: 12, +}; + // ── General tab ──────────────────────────────────────────────────────────────── function GeneralTab() { @@ -265,15 +437,29 @@ function ServicePromptCard({ label, initialSystem, initialUserTemplate, + defaultSystem, + defaultUserTemplate, }: { serviceId: string; label: string; initialSystem: string; initialUserTemplate: string; + defaultSystem: string; + defaultUserTemplate: string; }) { const queryClient = useQueryClient(); const [system, setSystem] = useState(initialSystem); const [userTemplate, setUserTemplate] = useState(initialUserTemplate); + const [showResetConfirm, setShowResetConfirm] = useState(false); + + const isAtDefault = + system === defaultSystem && userTemplate === defaultUserTemplate; + + const handleReset = () => { + setSystem(defaultSystem); + setUserTemplate(defaultUserTemplate); + setShowResetConfirm(false); + }; const mut = useMutation({ mutationFn: () => updateSystemPrompt(serviceId, { system, user_template: userTemplate }), @@ -289,7 +475,9 @@ function ServicePromptCard({ marginBottom: 24, }} > -

{label}

+

{label}

+ +