@@ -6,7 +6,10 @@ import {
66 Param ,
77 Patch ,
88 Post ,
9+ Res ,
910 UseGuards ,
11+ HttpException ,
12+ HttpStatus ,
1013} from '@nestjs/common' ;
1114import {
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' ;
2127import { AuthContext , OrganizationId } from '../auth/auth-context.decorator' ;
2228import { HybridAuthGuard } from '../auth/hybrid-auth.guard' ;
2329import type { AuthContext as AuthContextType } from '../auth/types' ;
2430import { CreatePolicyDto } from './dto/create-policy.dto' ;
2531import { UpdatePolicyDto } from './dto/update-policy.dto' ;
32+ import { AISuggestPolicyRequestDto } from './dto/ai-suggest-policy.dto' ;
2633import { PoliciesService } from './policies.service' ;
2734import { GET_ALL_POLICIES_RESPONSES } from './schemas/get-all-policies.responses' ;
2835import { 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}
0 commit comments