diff --git a/apps/app/src/components/decisions/ProcessBuilder/GenerateProcessModal.tsx b/apps/app/src/components/decisions/ProcessBuilder/GenerateProcessModal.tsx new file mode 100644 index 000000000..f1b33da7e --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/GenerateProcessModal.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import { Button } from '@op/ui/Button'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; +import { TextField } from '@op/ui/TextField'; +import { toast } from '@op/ui/Toast'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import { useTranslations } from '@/lib/i18n'; + +export const GenerateProcessModal = ({ + isOpen, + onOpenChange, +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}) => { + const t = useTranslations(); + const router = useRouter(); + const [description, setDescription] = useState(''); + + const generateProcess = + trpc.decision.generateProcessFromDescription.useMutation({ + onSuccess: (data) => { + onOpenChange(false); + router.push(`/decisions/${data.slug}/edit`); + }, + onError: (error) => { + toast.error({ + message: t('Failed to generate template'), + title: error.message, + }); + }, + }); + + const handleGenerate = () => { + if (description.trim().length < 10) { + return; + } + generateProcess.mutate({ description: description.trim() }); + }; + + return ( + + {t('Describe your decision-making process')} + +

+ {t( + "Tell us about your decision-making process and we'll create a template for you.", + )} +

+ +
+ + + + +
+ ); +}; diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderProcessSelector.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderProcessSelector.tsx index d6d3287f7..181105ba2 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderProcessSelector.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderProcessSelector.tsx @@ -6,10 +6,13 @@ import { Avatar } from '@op/ui/Avatar'; import { Header1, Header2 } from '@op/ui/Header'; import { Skeleton } from '@op/ui/Skeleton'; import { useRouter } from 'next/navigation'; -import { Suspense } from 'react'; +import { Suspense, useState } from 'react'; +import { LuSparkles } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; +import { GenerateProcessModal } from './GenerateProcessModal'; + export const ProcessBuilderProcessSelector = () => { const t = useTranslations(); @@ -34,6 +37,7 @@ const TemplateList = () => { const t = useTranslations(); const [templatesData] = trpc.decision.listProcesses.useSuspenseQuery({}); const templates = templatesData?.processes; + const [isGenerateModalOpen, setIsGenerateModalOpen] = useState(false); const createDecisionInstance = trpc.decision.createInstanceFromTemplate.useMutation({ @@ -42,26 +46,46 @@ const TemplateList = () => { }, }); - if (!templates?.length) { - return ( -
-

{t('No templates found')}

-
- ); - } + return ( + <> + + + {templates?.map((template) => ( + { + createDecisionInstance.mutate({ + templateId: template.id, + name: `New ${template.name}`, + }); + }} + /> + ))} - return templates.map((template) => ( - { - createDecisionInstance.mutate({ - templateId: template.id, - name: `New ${template.name}`, - }); - }} - /> - )); + + + ); }; export const ProcessBuilderProcessCard = ({ diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 101f11b6f..019b46481 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -690,5 +690,13 @@ "Complete all required phase fields": "Complete all required phase fields", "Create a proposal template": "Create a proposal template", "Fix errors in the proposal template": "Fix errors in the proposal template", - "Invite members": "Invite members" + "Invite members": "Invite members", + "Describe your process": "Describe your process", + "Tell us about your decision-making process and we'll create a template for you.": "Tell us about your decision-making process and we'll create a template for you.", + "Describe your decision-making process": "Describe your decision-making process", + "Generate template": "Generate template", + "Generating your template...": "Generating your template...", + "Failed to generate template": "Failed to generate template", + "Process description": "Process description", + "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting": "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index b3938a7bf..5e4209930 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -683,5 +683,13 @@ "Complete all required phase fields": "Complete all required phase fields", "Create a proposal template": "Create a proposal template", "Fix errors in the proposal template": "Fix errors in the proposal template", - "Invite members": "Invite members" + "Invite members": "Invite members", + "Describe your process": "Describe your process", + "Tell us about your decision-making process and we'll create a template for you.": "Tell us about your decision-making process and we'll create a template for you.", + "Describe your decision-making process": "Describe your decision-making process", + "Generate template": "Generate template", + "Generating your template...": "Generating your template...", + "Failed to generate template": "Failed to generate template", + "Process description": "Process description", + "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting": "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index 75c663116..03ccbdc91 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -682,5 +682,13 @@ "Complete all required phase fields": "Complete all required phase fields", "Create a proposal template": "Create a proposal template", "Fix errors in the proposal template": "Fix errors in the proposal template", - "Invite members": "Invite members" + "Invite members": "Invite members", + "Describe your process": "Describe your process", + "Tell us about your decision-making process and we'll create a template for you.": "Tell us about your decision-making process and we'll create a template for you.", + "Describe your decision-making process": "Describe your decision-making process", + "Generate template": "Generate template", + "Generating your template...": "Generating your template...", + "Failed to generate template": "Failed to generate template", + "Process description": "Process description", + "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting": "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index e8b1ce185..758e7ec27 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -682,5 +682,13 @@ "Complete all required phase fields": "Complete all required phase fields", "Create a proposal template": "Create a proposal template", "Fix errors in the proposal template": "Fix errors in the proposal template", - "Invite members": "Invite members" + "Invite members": "Invite members", + "Describe your process": "Describe your process", + "Tell us about your decision-making process and we'll create a template for you.": "Tell us about your decision-making process and we'll create a template for you.", + "Describe your decision-making process": "Describe your decision-making process", + "Generate template": "Generate template", + "Generating your template...": "Generating your template...", + "Failed to generate template": "Failed to generate template", + "Process description": "Process description", + "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting": "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 5e4eefe13..bced626ca 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -678,5 +678,13 @@ "Complete all required phase fields": "Complete all required phase fields", "Create a proposal template": "Create a proposal template", "Fix errors in the proposal template": "Fix errors in the proposal template", - "Invite members": "Invite members" + "Invite members": "Invite members", + "Describe your process": "Describe your process", + "Tell us about your decision-making process and we'll create a template for you.": "Tell us about your decision-making process and we'll create a template for you.", + "Describe your decision-making process": "Describe your decision-making process", + "Generate template": "Generate template", + "Generating your template...": "Generating your template...", + "Failed to generate template": "Failed to generate template", + "Process description": "Process description", + "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting": "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting" } diff --git a/services/api/src/routers/decision/processes/generateProcess.ts b/services/api/src/routers/decision/processes/generateProcess.ts new file mode 100644 index 000000000..8eb54556a --- /dev/null +++ b/services/api/src/routers/decision/processes/generateProcess.ts @@ -0,0 +1,314 @@ +import { anthropic } from '@ai-sdk/anthropic'; +import { assertUserByAuthId, createInstanceFromTemplateCore } from '@op/common'; +import { CommonError, UnauthorizedError } from '@op/common'; +import { db } from '@op/db/client'; +import { decisionProcesses } from '@op/db/schema'; +import { TRPCError } from '@trpc/server'; +import { generateObject } from 'ai'; +import { z } from 'zod'; + +import { decisionProfileWithSchemaEncoder } from '../../../encoders/decision'; +import { commonAuthedProcedure, router } from '../../../trpcFactory'; + +/** + * Zod schema matching DecisionSchemaDefinition for AI structured output. + */ +const phaseRulesSchema = z.object({ + proposals: z + .object({ + submit: z.boolean().optional(), + edit: z.boolean().optional(), + review: z.boolean().optional(), + }) + .optional(), + voting: z + .object({ + submit: z.boolean().optional(), + edit: z.boolean().optional(), + }) + .optional(), + advancement: z + .object({ + method: z.enum(['date', 'manual']), + }) + .optional(), +}); + +const selectionPipelineBlockSchema = z.object({ + id: z.string(), + type: z.enum(['sort', 'limit', 'filter']), + name: z.string().optional(), + sortBy: z + .array( + z.object({ + field: z.string(), + order: z.enum(['asc', 'desc']), + }), + ) + .optional(), + count: z.union([z.number(), z.object({ variable: z.string() })]).optional(), +}); + +const selectionPipelineSchema = z.object({ + version: z.string(), + blocks: z.array(selectionPipelineBlockSchema), +}); + +const settingsSchema = z.object({ + type: z.literal('object'), + required: z.array(z.string()).optional(), + properties: z.record( + z.string(), + z.object({ + type: z.enum(['number', 'string', 'boolean']), + title: z.string(), + description: z.string().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + default: z.union([z.number(), z.string(), z.boolean()]).optional(), + }), + ), + ui: z + .record( + z.string(), + z.object({ + 'ui:widget': z.string().optional(), + 'ui:placeholder': z.string().optional(), + }), + ) + .optional(), +}); + +const phaseDefinitionSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + rules: phaseRulesSchema, + selectionPipeline: selectionPipelineSchema.optional(), + settings: settingsSchema.optional(), +}); + +const processConfigSchema = z.object({ + hideBudget: z.boolean().optional(), + categories: z + .array( + z.object({ + id: z.string(), + label: z.string(), + description: z.string(), + }), + ) + .optional(), + requireCategorySelection: z.boolean().optional(), + allowMultipleCategories: z.boolean().optional(), +}); + +const decisionSchemaDefinitionSchema = z.object({ + id: z.string(), + version: z.string(), + name: z.string(), + description: z.string().optional(), + config: processConfigSchema.optional(), + phases: z.array(phaseDefinitionSchema).min(1), +}); + +const SYSTEM_PROMPT = `You are a decision process designer. Given a description of a decision-making process, generate a DecisionSchemaDefinition that models it. + +A DecisionSchemaDefinition defines the phases of a structured decision process. Each phase has: +- id: a kebab-case identifier +- name: human-readable name +- description: what happens in this phase +- rules: controls what actions are allowed (proposals.submit, voting.submit, etc.) and how the phase advances (date or manual) +- selectionPipeline (optional): how proposals are filtered/sorted when advancing to the next phase +- settings (optional): configurable settings for the phase as JSON Schema + +Key design principles: +1. Phases should flow logically (e.g., submission -> review -> voting -> results) +2. Only the submission phase should allow proposal submissions (proposals.submit: true) +3. Only the voting phase should allow vote submissions (voting.submit: true) +4. Use manual advancement by default (method: 'manual') +5. The id should be a unique kebab-case string +6. Always include a final "results" phase where everything is locked down +7. Keep settings simple and relevant to each phase +8. The top-level id should be a kebab-case identifier for the whole process +9. version should be "1.0.0" + +Here is an example of a Simple Voting process for reference: + +{ + "id": "simple", + "version": "1.0.0", + "name": "Simple Voting", + "description": "Basic approval voting where members vote for multiple proposals.", + "phases": [ + { + "id": "submission", + "name": "Proposal Submission", + "description": "Members submit proposals for consideration.", + "rules": { + "proposals": { "submit": true }, + "voting": { "submit": false }, + "advancement": { "method": "manual" } + }, + "settings": { + "type": "object", + "properties": { + "maxProposalsPerMember": { + "type": "number", + "title": "Maximum Proposals Per Member", + "description": "How many proposals can each member submit?", + "minimum": 1, + "default": 3 + } + } + } + }, + { + "id": "review", + "name": "Review & Shortlist", + "description": "Reviewers evaluate and shortlist proposals.", + "rules": { + "proposals": { "submit": false }, + "voting": { "submit": false }, + "advancement": { "method": "manual" } + } + }, + { + "id": "voting", + "name": "Voting", + "description": "Members vote on shortlisted proposals.", + "rules": { + "proposals": { "submit": false }, + "voting": { "submit": true }, + "advancement": { "method": "manual" } + }, + "settings": { + "type": "object", + "required": ["maxVotesPerMember"], + "properties": { + "maxVotesPerMember": { + "type": "number", + "title": "Maximum Votes Per Member", + "description": "How many proposals can each member vote for?", + "minimum": 1, + "default": 3 + } + } + }, + "selectionPipeline": { + "version": "1.0.0", + "blocks": [ + { + "id": "sort-by-likes", + "type": "sort", + "name": "Sort by likes count", + "sortBy": [{ "field": "voteData.likesCount", "order": "desc" }] + }, + { + "id": "limit-by-votes", + "type": "limit", + "name": "Take top N", + "count": { "variable": "maxVotesPerMember" } + } + ] + } + }, + { + "id": "results", + "name": "Results", + "description": "View final results and winning proposals.", + "rules": { + "proposals": { "submit": false }, + "voting": { "submit": false }, + "advancement": { "method": "manual" } + } + } + ] +} + +Generate a process definition appropriate for the user's description. Be creative but practical.`; + +export const generateProcessRouter = router({ + generateProcessFromDescription: commonAuthedProcedure({ + rateLimit: { windowSize: 60, maxRequests: 5 }, + }) + .input( + z.object({ + description: z.string().min(10).max(2000), + }), + ) + .output(decisionProfileWithSchemaEncoder) + .mutation(async ({ ctx, input }) => { + const { user, logger } = ctx; + + try { + // Resolve user info + const dbUser = await assertUserByAuthId(user.id); + const ownerProfileId = dbUser.currentProfileId ?? dbUser.profileId; + if (!ownerProfileId) { + throw new UnauthorizedError('User must have an active profile'); + } + if (!user.email) { + throw new CommonError('User must have an email address'); + } + + // Generate the decision schema from the description + const result = await generateObject({ + model: anthropic('claude-sonnet-4-5-20250929'), + system: SYSTEM_PROMPT, + prompt: input.description, + schema: decisionSchemaDefinitionSchema, + }); + const schema = result.object as z.infer< + typeof decisionSchemaDefinitionSchema + >; + + // Save the generated schema as a new decision process template + const [template] = await db + .insert(decisionProcesses) + .values({ + name: schema.name, + description: schema.description, + processSchema: schema, + createdByProfileId: ownerProfileId, + }) + .returning(); + + if (!template) { + throw new CommonError('Failed to create decision process template'); + } + + // Create an instance from the new template + const profile = await createInstanceFromTemplateCore({ + templateId: template.id, + name: schema.name, + description: schema.description, + ownerProfileId, + creatorAuthUserId: user.id, + creatorEmail: user.email, + }); + + logger.info('AI-generated decision process created', { + userId: user.id, + processName: schema.name, + templateId: template.id, + }); + + return decisionProfileWithSchemaEncoder.parse(profile); + } catch (error: unknown) { + logger.error('Failed to generate decision process', { + userId: user.id, + error, + }); + + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ + message: 'Failed to generate decision process', + code: 'INTERNAL_SERVER_ERROR', + }); + } + }), +}); diff --git a/services/api/src/routers/decision/processes/index.ts b/services/api/src/routers/decision/processes/index.ts index aa7af7df0..e39e75b02 100644 --- a/services/api/src/routers/decision/processes/index.ts +++ b/services/api/src/routers/decision/processes/index.ts @@ -1,10 +1,12 @@ import { mergeRouters } from '../../../trpcFactory'; import { createProcessRouter } from './createProcess'; +import { generateProcessRouter } from './generateProcess'; import { getProcessRouter } from './getProcess'; import { listProcessesRouter } from './listProcesses'; export const processesRouter = mergeRouters( createProcessRouter, + generateProcessRouter, getProcessRouter, listProcessesRouter, ); diff --git a/turbo.json b/turbo.json index b064a1194..1628c050c 100644 --- a/turbo.json +++ b/turbo.json @@ -47,6 +47,7 @@ "MAILCHIMP_API_KEY", "MAILCHIMP_API_SERVER", "MAILCHIMP_AUDIENCE_ID", + "ANTHROPIC_API_KEY", "🌎 ------ PUBLIC VARS ------ 🌎", "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY",