diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx index ab303af210..cc784717a5 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx @@ -10,22 +10,16 @@ import '@comp/ui/editor.css'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs'; import type { PolicyDisplayFormat } from '@db'; import type { JSONContent } from '@tiptap/react'; -import { - DefaultChatTransport, - getToolName, - isToolUIPart, - type ToolUIPart, - type UIMessage, -} from 'ai'; +import { DefaultChatTransport } from 'ai'; import { structuredPatch } from 'diff'; import { CheckCircle, Loader2, Sparkles, X } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; -import { useFeatureFlagEnabled } from 'posthog-js/react'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { switchPolicyDisplayFormatAction } from '../../actions/switch-policy-display-format'; import { PdfViewer } from '../../components/PdfViewer'; import { updatePolicy } from '../actions/update-policy'; +import type { PolicyChatUIMessage } from '../types'; import { markdownToTipTapJSON } from './ai/markdown-utils'; import { PolicyAiAssistant } from './ai/policy-ai-assistant'; @@ -49,24 +43,32 @@ interface LatestProposal { key: string; content: string; summary: string; + title: string; + detail: string; + reviewHint: string; } -function getLatestProposedPolicy(messages: UIMessage[]): LatestProposal | null { +function getLatestProposedPolicy(messages: PolicyChatUIMessage[]): LatestProposal | null { const lastAssistantMessage = [...messages].reverse().find((m) => m.role === 'assistant'); if (!lastAssistantMessage?.parts) return null; let latest: LatestProposal | null = null; lastAssistantMessage.parts.forEach((part, index) => { - if (!isToolUIPart(part) || getToolName(part) !== 'proposePolicy') return; - const toolPart = part as ToolUIPart; - const input = toolPart.input as { content?: string; summary?: string } | undefined; + if (part.type !== 'tool-proposePolicy') return; + if (part.state === 'input-streaming' || part.state === 'output-error') return; + const input = part.input; if (!input?.content) return; latest = { key: `${lastAssistantMessage.id}:${index}`, content: input.content, summary: input.summary ?? 'Proposing policy changes', + title: input.title ?? input.summary ?? 'Policy updates ready for your review', + detail: + input.detail ?? + 'I have prepared an updated version of this policy based on your instructions.', + reviewHint: input.reviewHint ?? 'Review the proposed changes below before applying them.', }; }); @@ -100,13 +102,18 @@ export function PolicyContentManager({ const [dismissedProposalKey, setDismissedProposalKey] = useState(null); const [isApplying, setIsApplying] = useState(false); const [chatErrorMessage, setChatErrorMessage] = useState(null); - const isAiPolicyAssistantEnabled = useFeatureFlagEnabled('is-ai-policy-assistant-enabled'); + const diffViewerRef = useRef(null); + + const isAiPolicyAssistantEnabled = true; + const scrollToDiffViewer = useCallback(() => { + diffViewerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, []); const { messages, status, sendMessage: baseSendMessage, - } = useChat({ + } = useChat({ transport: new DefaultChatTransport({ api: `/api/policies/${policyId}/chat`, }), @@ -128,6 +135,20 @@ export function PolicyContentManager({ const proposedPolicyMarkdown = activeProposal?.content ?? null; + const hasPendingProposal = useMemo( + () => + messages.some( + (m) => + m.role === 'assistant' && + m.parts?.some( + (part) => + part.type === 'tool-proposePolicy' && + (part.state === 'input-streaming' || part.state === 'input-available'), + ), + ), + [messages], + ); + const switchFormat = useAction(switchPolicyDisplayFormatAction, { onError: () => toast.error('Failed to switch view.'), }); @@ -167,8 +188,8 @@ export function PolicyContentManager({
-
-
+
+
@@ -219,13 +240,15 @@ export function PolicyContentManager({
{showAiAssistant && isAiPolicyAssistantEnabled && ( -
+
setShowAiAssistant(false)} + onScrollToDiff={scrollToDiffViewer} + hasActiveProposal={!!activeProposal && !hasPendingProposal} />
)} @@ -233,8 +256,8 @@ export function PolicyContentManager({ - {proposedPolicyMarkdown && diffPatch && activeProposal && ( -
+ {proposedPolicyMarkdown && diffPatch && activeProposal && !hasPendingProposal && ( +
+ )}

); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/tools/policy-tools.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/tools/policy-tools.ts new file mode 100644 index 0000000000..1b0e1b01bc --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/tools/policy-tools.ts @@ -0,0 +1,45 @@ +import { type InferUITools, tool } from 'ai'; +import { z } from 'zod'; + +export function getPolicyTools() { + return { + proposePolicy: tool({ + description: + 'Propose an updated version of the policy. Use this tool whenever the user asks you to make changes, edits, or improvements to the policy. You must provide the COMPLETE policy content, not just the changes.', + inputSchema: z.object({ + content: z + .string() + .describe( + 'The complete updated policy content in markdown format. Must include the entire policy, not just the changed sections.', + ), + summary: z + .string() + .describe('One to two sentences summarizing the changes. No bullet points.'), + title: z + .string() + .describe( + 'A short, sentence-case heading (~4–10 words) that clearly states the main change, for use in a small review banner.', + ), + detail: z + .string() + .describe( + 'One or two plain-text sentences briefly explaining what changed and why, shown in the review banner.', + ), + reviewHint: z + .string() + .describe( + 'A very short imperative phrase that tells the user to review the updated policy content in the editor below.', + ), + }), + execute: async ({ summary, title, detail, reviewHint }) => ({ + success: true, + summary, + title, + detail, + reviewHint, + }), + }), + }; +} + +export type PolicyToolSet = InferUITools>; diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts index d08b5b2339..857b62bc7c 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts @@ -1,4 +1,8 @@ +import type { UIMessage } from 'ai'; import { z } from 'zod'; +import type { PolicyToolSet } from '../tools/policy-tools'; + +export type PolicyChatUIMessage = UIMessage; export const policyDetailsSchema = z.object({ id: z.string(), diff --git a/apps/app/src/app/api/policies/[policyId]/chat/route.ts b/apps/app/src/app/api/policies/[policyId]/chat/route.ts index c7aacd8220..b3866194c1 100644 --- a/apps/app/src/app/api/policies/[policyId]/chat/route.ts +++ b/apps/app/src/app/api/policies/[policyId]/chat/route.ts @@ -1,10 +1,10 @@ +import { getPolicyTools } from '@/app/(app)/[orgId]/policies/[policyId]/editor/tools/policy-tools'; import { auth } from '@/utils/auth'; import { openai } from '@ai-sdk/openai'; import { db } from '@db'; -import { convertToModelMessages, streamText, tool, type UIMessage } from 'ai'; +import { convertToModelMessages, streamText, type UIMessage } from 'ai'; import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; -import { z } from 'zod'; export const maxDuration = 60; @@ -80,43 +80,44 @@ Current Policy Content: ${policyContentText} --- -IMPORTANT: This assistant is ONLY for editing policies. You MUST always use one of the available tools. - Your role: 1. Edit and improve policies when asked 2. Ensure policies remain compliant with relevant frameworks 3. Maintain professional, clear language appropriate for official documentation -TOOL USAGE (MANDATORY): -- If the user asks you to make changes, edits, or improvements: use the proposePolicy tool -- If the user asks a question or anything that is NOT an edit request: use the returnQuestion tool - -COMMUNICATION STYLE: -- Be concise and direct. No lengthy explanations or preamble. -- Do not use bullet points in responses unless asked. -- One sentence to explain, then act. +WHEN TO USE THE proposePolicy TOOL: +- When the user explicitly asks you to make changes, edits, or improvements +- When you have a clear understanding of what changes to make +- Always provide the COMPLETE policy content, not just changes -WHEN MAKING POLICY CHANGES: -Use the proposePolicy tool immediately. State what you'll change in ONE sentence, then call the tool. +WHEN TO RESPOND WITHOUT THE TOOL: +- When you need to ask clarifying questions about what the user wants +- When the request is ambiguous and you need more information +- When acknowledging the user or providing brief explanations -WHEN USER ASKS A QUESTION: -Use the returnQuestion tool immediately. Do not answer the question directly. +COMMUNICATION STYLE: +- Be concise and direct +- Ask clarifying questions when the user's intent is unclear +- One sentence to explain, then act (use tool or ask question) +- Your conversational messages to the user must be plain text only (no markdown headers, bold, italics, bullet points, or code blocks) +- Note: The policy content in proposePolicy tool MUST still use proper markdown formatting -CRITICAL MARKDOWN FORMATTING RULES: +CRITICAL MARKDOWN FORMATTING RULES (when using proposePolicy): - Every heading MUST have text after the # symbols (e.g., "## Section Title", never just "##") - Preserve the original document structure and all sections - Use proper heading hierarchy (# for title, ## for sections, ### for subsections) -- Ensure all lists are properly formatted with consistent indentation - Do not leave any empty headings, paragraphs, or incomplete syntax - Do not truncate or abbreviate any section - include full content -- If you're unsure about a section, keep the original text -QUALITY CHECKLIST before submitting: -- All headings have proper titles after # symbols -- Document starts with a clear title (# Policy Title) -- All original sections are preserved unless explicitly asked to remove -- No markdown syntax errors (unclosed brackets, incomplete lists) -- Professional tone maintained throughout +BANNER METADATA (for the proposePolicy tool arguments): +- title: A short, sentence-case heading (~4–10 words) that clearly states the main change. Example: "Data retention protocols integrated". +- detail: One or two sentences (plain text, no bullet points) briefly explaining what you changed and why, in a calm and professional tone. +- reviewHint: A very short imperative phrase that tells the user to review the updated policy in the editor below (for example, "Review the updated Data retention section below."). + +When using the proposePolicy tool: +- Always provide the COMPLETE updated policy content in the content field. +- Always fill in title, detail, and reviewHint so the UI can show a small banner indicating that changes are ready to review. +- Keep title, detail, and reviewHint focused, specific, and free of markdown formatting. Keep responses helpful and focused on the policy editing task.`; @@ -125,37 +126,8 @@ Keep responses helpful and focused on the policy editing task.`; model: openai('gpt-5.1'), system: systemPrompt, messages: convertToModelMessages(messages), - toolChoice: 'required', - tools: { - proposePolicy: tool({ - description: - 'Propose an updated version of the policy. Use this tool whenever the user asks you to make changes, edits, or improvements to the policy. You must provide the COMPLETE policy content, not just the changes.', - inputSchema: z.object({ - content: z - .string() - .describe( - 'The complete updated policy content in markdown format. Must include the entire policy, not just the changed sections.', - ), - summary: z - .string() - .describe('One to two sentences summarizing the changes. No bullet points.'), - }), - execute: async ({ summary }) => ({ success: true, summary }), - }), - returnQuestion: tool({ - description: - 'Use this tool when the user asks a question instead of requesting an edit. This assistant is only for editing policies, not answering questions.', - inputSchema: z.object({ - question: z.string().describe('The question the user asked.'), - message: z - .string() - .describe( - 'A brief message explaining that this assistant is only for editing policies and suggesting they rephrase as an edit request.', - ), - }), - execute: async ({ question, message }) => ({ success: true, question, message }), - }), - }, + toolChoice: 'auto', + tools: getPolicyTools(), }); return result.toUIMessageStreamResponse(); diff --git a/apps/app/src/components/editor/policy-editor.tsx b/apps/app/src/components/editor/policy-editor.tsx index 95d631a99a..2540aa08c3 100644 --- a/apps/app/src/components/editor/policy-editor.tsx +++ b/apps/app/src/components/editor/policy-editor.tsx @@ -2,7 +2,6 @@ import { validateAndFixTipTapContent } from '@comp/ui/editor'; import type { JSONContent } from '@tiptap/react'; -import { useState } from 'react'; import AdvancedEditor from './advanced-editor'; interface PolicyEditorProps { @@ -12,17 +11,11 @@ interface PolicyEditorProps { } export function PolicyEditor({ content, readOnly = false, onSave }: PolicyEditorProps) { - const [editorContent, setEditorContent] = useState(null); - const documentContent = validateAndFixTipTapContent({ type: 'doc', content: Array.isArray(content) && content.length > 0 ? content : [], }); - const handleUpdate = (updatedContent: JSONContent) => { - setEditorContent(updatedContent); - }; - const handleSave = async (contentToSave: JSONContent): Promise => { if (!contentToSave || !onSave) return; @@ -38,12 +31,7 @@ export function PolicyEditor({ content, readOnly = false, onSave }: PolicyEditor return ( <> - + ); } diff --git a/package.json b/package.json index 8496c16f0d..22548f1f26 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "lint-staged": { "*.{js,jsx,ts,tsx,json,md}": "prettier --write" }, - "packageManager": "bun@1.1.36", + "packageManager": "bun@1.3.3", "private": true, "scripts": { "build": "turbo build", diff --git a/packages/docs/ai-policy-editor.mdx b/packages/docs/ai-policy-editor.mdx new file mode 100644 index 0000000000..996373178d --- /dev/null +++ b/packages/docs/ai-policy-editor.mdx @@ -0,0 +1,111 @@ +--- +title: 'AI Policy Editor' +description: 'Use AI to edit and improve organizational policies for SOC 2, ISO 27001, and GDPR compliance' +--- + +### About AI Policy Editor + +The AI Policy Editor is an intelligent assistant that helps you create, edit, and improve organizational policies. Built with deep knowledge of compliance frameworks like SOC 2, ISO 27001, and GDPR, the assistant understands governance, risk, and compliance (GRC) requirements and can help ensure your policies meet industry standards. + +**Key Benefits:** + +- **Natural Language Editing**: Describe changes in plain English and let AI handle the formatting +- **Compliance Expertise**: Get suggestions aligned with SOC 2, ISO 27001, GDPR, and other frameworks +- **Visual Diff Review**: See exactly what changed before applying any edits +- **Non-Destructive Workflow**: Review and approve all changes before they're saved + +### Getting Started + +#### Step 1: Open the Policy Editor + +1. Navigate to **Policies** in your organization dashboard +2. Click on any policy to open it +3. Select the **Editor View** tab + +#### Step 2: Enable the AI Assistant + +1. Click the **AI Assistant** button in the top-right corner of the editor +2. A chat panel will appear on the right side of the screen +3. You're now ready to start editing with AI + + + AI Policy Editor Assistant Panel + + +### Using the AI Assistant + +The AI assistant responds to natural language requests. Simply describe what you want to change, and the assistant will propose a complete updated version of your policy. + +**Example Prompts:** + +| Request Type | Example Prompt | +| ------------------- | ------------------------------------------------------------------------------------ | +| Add content | "Add a data retention section that specifies 7-year retention for financial records" | +| Improve compliance | "Make this policy more SOC 2 compliant" | +| Simplify language | "Simplify the language to be more readable" | +| Add specifics | "Add specific roles and responsibilities for incident response" | +| Framework alignment | "Update this to align with ISO 27001 Annex A controls" | + + + The AI always proposes complete policy content, not just fragments. This ensures formatting + consistency and prevents accidental content loss. + + +### Reviewing Proposed Changes + +When the AI proposes changes, you'll see a status indicator showing the progress: + +- **Drafting**: The AI is preparing the updated policy +- **Running**: The proposal is being processed +- **Completed**: Changes are ready for your review + +Once completed, a diff viewer appears below the editor showing: + +- **Red lines**: Content being removed +- **Green lines**: Content being added + + + AI Policy Editor Diff Viewer + + +#### Applying or Dismissing Changes + +After reviewing the diff: + +1. Click **Apply Changes** to accept the proposed edits +2. Click **Dismiss** to reject the changes and keep your current content + +Click "View proposed changes" in the chat to quickly scroll to the diff viewer. + +### Best Practices + +1. **Be Specific**: The more detail you provide in your request, the better the results. Instead of "improve this policy," try "add a section about password complexity requirements with 12-character minimum." + +2. **Review Before Applying**: Always review the diff carefully before applying changes. The AI provides complete rewrites, so verify all sections are preserved as expected. + +3. **Iterative Editing**: Make changes incrementally. Request one type of change at a time for easier review and better control over the final result. + +4. **Ask Questions First**: If you're unsure what changes to make, ask the AI for suggestions. It can explain compliance requirements and recommend improvements before you commit to changes. + +5. **Keep Policies Published**: Ensure your policies are published so other AI features (like Security Questionnaire) can reference the most current versions. + +### Troubleshooting + +**AI Assistant Not Appearing** + +Ensure you're viewing the policy in **Editor View** mode, not PDF View. The AI assistant is only available in the editor. + +**Changes Not Applying** + +If changes fail to apply, check your network connection, ensure you have edit permissions for the policy, and try refreshing the page. + +**Unexpected Content Changes** + +If the AI modified sections you didn't intend to change, click **Dismiss** to reject the changes. Be more specific in your next request about which sections to modify, or reference sections by name. + +### Support + +For additional assistance with the AI Policy Editor: + +1. Contact support at [support@trycomp.ai](mailto:support@trycomp.ai) +2. Join our [Discord community](https://discord.gg/compai) for peer support diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 338d2ece63..1116e3909a 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -17,6 +17,7 @@ "group": "Get Started", "pages": [ "introduction", + "ai-policy-editor", "automated-evidence", "device-agent", "security-questionnaire", diff --git a/packages/docs/images/ai-policy-editor-assistant.png b/packages/docs/images/ai-policy-editor-assistant.png new file mode 100644 index 0000000000..a7f1a1e7c2 Binary files /dev/null and b/packages/docs/images/ai-policy-editor-assistant.png differ diff --git a/packages/docs/images/ai-policy-editor-diff-viewer.png b/packages/docs/images/ai-policy-editor-diff-viewer.png new file mode 100644 index 0000000000..005a892ec6 Binary files /dev/null and b/packages/docs/images/ai-policy-editor-diff-viewer.png differ diff --git a/packages/ui/src/components/ai-elements/conversation.tsx b/packages/ui/src/components/ai-elements/conversation.tsx index bc31c152ec..12484ecdd0 100644 --- a/packages/ui/src/components/ai-elements/conversation.tsx +++ b/packages/ui/src/components/ai-elements/conversation.tsx @@ -11,7 +11,10 @@ export type ConversationProps = ComponentProps; export const Conversation = ({ className, ...props }: ConversationProps) => ( ( export type ConversationContentProps = ComponentProps; export const ConversationContent = ({ className, ...props }: ConversationContentProps) => ( - + ); export type ConversationHeaderProps = ComponentProps<'div'>; export const ConversationHeader = ({ className, ...props }: ConversationHeaderProps) => (
); @@ -37,7 +50,13 @@ export const ConversationHeader = ({ className, ...props }: ConversationHeaderPr export type ConversationTitleProps = ComponentProps<'div'>; export const ConversationTitle = ({ className, ...props }: ConversationTitleProps) => ( -
+
); export type ConversationEmptyStateProps = ComponentProps<'div'> & { @@ -56,7 +75,7 @@ export const ConversationEmptyState = ({ }: ConversationEmptyStateProps) => (
{icon &&
{icon}
}
-

{title}

- {description &&

{description}

} +

+ {title} +

+ {description && ( +

{description}

+ )}
)} @@ -88,7 +111,11 @@ export const ConversationScrollButton = ({ return ( !isAtBottom && (