Skip to content

Commit 56dfd67

Browse files
committed
chore: merge main into release for new releases
2 parents eccf260 + fb9b82a commit 56dfd67

File tree

34 files changed

+4078
-212
lines changed

34 files changed

+4078
-212
lines changed

apps/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
"version": "0.0.1",
55
"author": "",
66
"dependencies": {
7+
"@ai-sdk/openai": "^2.0.65",
78
"@aws-sdk/client-s3": "^3.859.0",
9+
"ai": "^5.0.60",
810
"@aws-sdk/s3-request-presigner": "^3.859.0",
911
"@nestjs/common": "^11.0.1",
1012
"@nestjs/config": "^4.0.2",
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsString, IsOptional, IsArray } from 'class-validator';
3+
4+
export class AISuggestPolicyRequestDto {
5+
@ApiProperty({
6+
description: 'User instructions about what changes to make to the policy',
7+
example: 'Update the data retention section to specify a 7-year retention period',
8+
})
9+
@IsString()
10+
instructions: string;
11+
12+
@ApiProperty({
13+
description:
14+
'Chat history for context (array of messages with role and content)',
15+
example: [
16+
{ role: 'user', content: 'Update the data retention policy' },
17+
{ role: 'assistant', content: 'I can help with that...' },
18+
],
19+
required: false,
20+
type: 'array',
21+
items: {
22+
type: 'object',
23+
properties: {
24+
role: { type: 'string', enum: ['user', 'assistant'] },
25+
content: { type: 'string' },
26+
},
27+
},
28+
})
29+
@IsOptional()
30+
@IsArray()
31+
chatHistory?: Array<{ role: 'user' | 'assistant'; content: string }>;
32+
}

apps/api/src/policies/policies.controller.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import {
66
Param,
77
Patch,
88
Post,
9+
Res,
910
UseGuards,
11+
HttpException,
12+
HttpStatus,
1013
} from '@nestjs/common';
1114
import {
1215
ApiBody,
@@ -18,11 +21,15 @@ import {
1821
ApiTags,
1922
ApiExtraModels,
2023
} from '@nestjs/swagger';
24+
import type { Response } from 'express';
25+
import { openai } from '@ai-sdk/openai';
26+
import { streamText, convertToModelMessages, type UIMessage } from 'ai';
2127
import { AuthContext, OrganizationId } from '../auth/auth-context.decorator';
2228
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
2329
import type { AuthContext as AuthContextType } from '../auth/types';
2430
import { CreatePolicyDto } from './dto/create-policy.dto';
2531
import { UpdatePolicyDto } from './dto/update-policy.dto';
32+
import { AISuggestPolicyRequestDto } from './dto/ai-suggest-policy.dto';
2633
import { PoliciesService } from './policies.service';
2734
import { GET_ALL_POLICIES_RESPONSES } from './schemas/get-all-policies.responses';
2835
import { GET_POLICY_BY_ID_RESPONSES } from './schemas/get-policy-by-id.responses';
@@ -179,4 +186,133 @@ export class PoliciesController {
179186
}),
180187
};
181188
}
189+
190+
@Post(':id/ai-chat')
191+
@ApiOperation({
192+
summary: 'Chat with AI about a policy',
193+
description:
194+
'Stream AI responses for policy editing assistance. Returns a text/event-stream with AI-generated suggestions.',
195+
})
196+
@ApiParam(POLICY_PARAMS.policyId)
197+
@ApiBody({ type: AISuggestPolicyRequestDto })
198+
@ApiResponse({
199+
status: 200,
200+
description: 'Streaming AI response',
201+
content: {
202+
'text/event-stream': {
203+
schema: { type: 'string' },
204+
},
205+
},
206+
})
207+
@ApiResponse({ status: 401, description: 'Unauthorized' })
208+
@ApiResponse({ status: 404, description: 'Policy not found' })
209+
async aiChatPolicy(
210+
@Param('id') id: string,
211+
@OrganizationId() organizationId: string,
212+
@Body() body: AISuggestPolicyRequestDto,
213+
@Res() res: Response,
214+
) {
215+
if (!process.env.OPENAI_API_KEY) {
216+
throw new HttpException(
217+
'AI service not configured',
218+
HttpStatus.SERVICE_UNAVAILABLE,
219+
);
220+
}
221+
222+
const policy = await this.policiesService.findById(id, organizationId);
223+
224+
const policyContentText = this.convertPolicyContentToText(policy.content);
225+
226+
const systemPrompt = `You are an expert GRC (Governance, Risk, and Compliance) policy editor. You help users edit and improve their organizational policies to meet compliance requirements like SOC 2, ISO 27001, and GDPR.
227+
228+
Current Policy Name: ${policy.name}
229+
${policy.description ? `Policy Description: ${policy.description}` : ''}
230+
231+
Current Policy Content:
232+
---
233+
${policyContentText}
234+
---
235+
236+
Your role:
237+
1. Help users understand and improve their policies
238+
2. Suggest specific changes when asked
239+
3. Ensure policies remain compliant with relevant frameworks
240+
4. Maintain professional, clear language appropriate for official documentation
241+
242+
When the user asks you to make changes to the policy:
243+
1. First explain what changes you'll make and why
244+
2. Then provide the COMPLETE updated policy content in a code block with the label \`\`\`policy
245+
3. The policy content inside the code block should be in markdown format
246+
247+
IMPORTANT: When providing updated policy content, you MUST include the ENTIRE policy, not just the changed sections. The content in the \`\`\`policy code block will replace the entire current policy.
248+
249+
Keep responses helpful and focused on the policy editing task.`;
250+
251+
const messages: UIMessage[] = [
252+
...(body.chatHistory || []).map((msg) => ({
253+
id: crypto.randomUUID(),
254+
role: msg.role,
255+
content: msg.content,
256+
parts: [{ type: 'text' as const, text: msg.content }],
257+
})),
258+
{
259+
id: crypto.randomUUID(),
260+
role: 'user' as const,
261+
content: body.instructions,
262+
parts: [{ type: 'text' as const, text: body.instructions }],
263+
},
264+
];
265+
266+
const result = streamText({
267+
model: openai('gpt-5.1'),
268+
system: systemPrompt,
269+
messages: convertToModelMessages(messages),
270+
});
271+
272+
return result.pipeTextStreamToResponse(res);
273+
}
274+
275+
private convertPolicyContentToText(content: unknown): string {
276+
if (!content) return '';
277+
278+
const contentArray = Array.isArray(content) ? content : [content];
279+
280+
const extractText = (node: unknown): string => {
281+
if (!node || typeof node !== 'object') return '';
282+
283+
const n = node as Record<string, unknown>;
284+
285+
if (n.type === 'text' && typeof n.text === 'string') {
286+
return n.text;
287+
}
288+
289+
if (Array.isArray(n.content)) {
290+
const texts = n.content.map(extractText).filter(Boolean);
291+
292+
switch (n.type) {
293+
case 'heading': {
294+
const level = (n.attrs as Record<string, unknown>)?.level || 1;
295+
return (
296+
'\n' + '#'.repeat(Number(level)) + ' ' + texts.join('') + '\n'
297+
);
298+
}
299+
case 'paragraph':
300+
return texts.join('') + '\n';
301+
case 'bulletList':
302+
case 'orderedList':
303+
return '\n' + texts.join('');
304+
case 'listItem':
305+
return '- ' + texts.join('') + '\n';
306+
case 'blockquote':
307+
return '\n> ' + texts.join('\n> ') + '\n';
308+
default:
309+
return texts.join('');
310+
}
311+
}
312+
313+
return '';
314+
};
315+
316+
return contentArray.map(extractText).join('\n').trim();
317+
}
182318
}

apps/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"canvas-confetti": "^1.9.3",
7171
"d3": "^7.9.0",
7272
"date-fns": "^4.1.0",
73+
"diff": "^8.0.2",
7374
"dub": "^0.66.1",
7475
"framer-motion": "^12.18.1",
7576
"geist": "^1.3.1",

0 commit comments

Comments
 (0)