1
+ 'use server' ;
2
+
3
+ import { z } from 'zod' ;
4
+ import { ObjectId } from 'mongodb' ; // For cardId validation if needed
5
+ import { getOpenAIClient } from '@/lib/openai' ;
6
+ import { verifyAuthToken } from '@/lib/auth' ;
7
+ import { fetchDeckByIdAction } from './decks' ; // To get deck name
8
+ import { fetchDeckCardsAction } from './cards' ; // To get current cards
9
+ import type {
10
+ AICreateCardSuggestion ,
11
+ AIUpdateCardSuggestion ,
12
+ AIDeleteCardSuggestion ,
13
+ AICardEditSuggestion ,
14
+ Card // For serializing current cards
15
+ } from '@/types' ;
16
+
17
+ // --- Zod Schemas for AI Edit Suggestions ---
18
+
19
+ const AICreateCardSchema = z . object ( {
20
+ type : z . literal ( 'create' ) ,
21
+ front_text : z . string ( ) . trim ( ) . min ( 1 , "Front text cannot be empty for new card" ) ,
22
+ back_text : z . string ( ) . trim ( ) . min ( 1 , "Back text cannot be empty for new card" ) ,
23
+ extra_context : z . string ( ) . optional ( ) ,
24
+ } ) ;
25
+
26
+ const AIUpdateCardSchema = z . object ( {
27
+ type : z . literal ( 'update' ) ,
28
+ cardId : z . string ( ) . refine ( val => ObjectId . isValid ( val ) , { message : "Invalid cardId format for update" } ) ,
29
+ front_text : z . string ( ) . trim ( ) . min ( 1 ) . optional ( ) ,
30
+ back_text : z . string ( ) . trim ( ) . min ( 1 ) . optional ( ) ,
31
+ extra_context : z . string ( ) . optional ( ) ,
32
+ } ) ;
33
+
34
+ const AIDeleteCardSchema = z . object ( {
35
+ type : z . literal ( 'delete' ) ,
36
+ cardId : z . string ( ) . refine ( val => ObjectId . isValid ( val ) , { message : "Invalid cardId format for delete" } ) ,
37
+ } ) ;
38
+
39
+ const AIEditSuggestionSchema = z . discriminatedUnion ( "type" , [
40
+ AICreateCardSchema ,
41
+ AIUpdateCardSchema ,
42
+ AIDeleteCardSchema ,
43
+ ] ) ;
44
+
45
+ const AIEditResponseSchema = z . object ( {
46
+ edits : z . array ( AIEditSuggestionSchema ) ,
47
+ } ) ;
48
+
49
+
50
+ // --- Action: Get AI Edit Suggestions ---
51
+
52
+ interface GetAIEditSuggestionsResult {
53
+ success : boolean ;
54
+ suggestions ?: AICardEditSuggestion [ ] ;
55
+ message ?: string ;
56
+ }
57
+
58
+ export async function getAIEditSuggestionsAction (
59
+ deckId : string ,
60
+ userPrompt : string ,
61
+ token : string | undefined
62
+ ) : Promise < GetAIEditSuggestionsResult > {
63
+ const authResult = verifyAuthToken ( token ) ;
64
+ if ( ! authResult ) {
65
+ return { success : false , message : 'Unauthorized.' } ;
66
+ }
67
+
68
+ if ( ! deckId || ! ObjectId . isValid ( deckId ) ) {
69
+ return { success : false , message : 'Invalid Deck ID.' } ;
70
+ }
71
+ if ( ! userPrompt || ! userPrompt . trim ( ) ) {
72
+ return { success : false , message : 'Prompt cannot be empty.' } ;
73
+ }
74
+
75
+ try {
76
+ const openai = getOpenAIClient ( ) ;
77
+
78
+ // 1. Fetch current deck and cards
79
+ const deckResult = await fetchDeckByIdAction ( deckId ) ; // Does not require token as per its definition
80
+ if ( ! deckResult . success || ! deckResult . deck ) {
81
+ return { success : false , message : deckResult . message || 'Failed to fetch deck details.' } ;
82
+ }
83
+
84
+ const cardsResult = await fetchDeckCardsAction ( deckId ) ;
85
+ if ( ! cardsResult . success || ! cardsResult . cards ) {
86
+ return { success : false , message : cardsResult . message || 'Failed to fetch deck cards.' } ;
87
+ }
88
+
89
+ // 2. Serialize deck state for the LLM
90
+ const currentCardsSerialized = cardsResult . cards . map ( card => ( {
91
+ id : card . id ,
92
+ front_text : card . front_text ,
93
+ back_text : card . back_text ,
94
+ // Do not include extra_context here unless we also want AI to edit it based on current value
95
+ } ) ) ;
96
+
97
+ const systemPrompt = `You are an AI assistant helping to manage flashcard decks. Based on the user's request and the current state of the deck, provide a list of edits. The deck name is "${ deckResult . deck . name } ".
98
+
99
+ Current cards in the deck (only id, front_text, back_text are shown):
100
+ ${ JSON . stringify ( currentCardsSerialized , null , 2 ) }
101
+
102
+ User's request: "${ userPrompt } "
103
+
104
+ Instructions for your response:
105
+ - Respond with a JSON object containing a single key "edits", which is an array of edit objects.
106
+ - Each edit object must have a "type" field: "create", "update", or "delete".
107
+ - For "create": include "front_text" (string), "back_text" (string), and optionally "extra_context" (string).
108
+ - For "update": include "cardId" (string - ID of the card to change) and AT LEAST ONE of "front_text" (string), "back_text" (string), or "extra_context" (string).
109
+ - For "delete": include "cardId" (string - ID of the card to remove).
110
+ - If no edits are needed, return an empty "edits" array: {"edits": []}.
111
+ - Ensure card IDs for updates/deletes are from the provided current card list.
112
+ - If the user asks to add cards, provide 'create' operations.
113
+ - If the user asks to change existing cards, provide 'update' operations with the relevant cardId.
114
+ - If the user asks to remove cards, provide 'delete' operations with the relevant cardId.
115
+ - Be precise. Do not add conversational fluff. Only provide the JSON object.` ;
116
+
117
+ const completion = await openai . chat . completions . create ( {
118
+ model : 'gpt-4o' , // Or your preferred model
119
+ messages : [ { role : 'system' , content : systemPrompt } ] ,
120
+ response_format : { type : 'json_object' } ,
121
+ temperature : 0.3 , // Lower temperature for more predictable edits
122
+ } ) ;
123
+
124
+ const content = completion . choices [ 0 ] ?. message ?. content ;
125
+ if ( ! content ) {
126
+ throw new Error ( 'OpenAI did not return content for edit suggestions.' ) ;
127
+ }
128
+
129
+ let parsedContent ;
130
+ try {
131
+ parsedContent = JSON . parse ( content ) ;
132
+ } catch ( parseError ) {
133
+ console . error ( 'Failed to parse OpenAI JSON response for edits:' , parseError , 'Content:' , content ) ;
134
+ throw new Error ( 'Failed to parse edit suggestions from AI response.' ) ;
135
+ }
136
+
137
+ const validationResult = AIEditResponseSchema . safeParse ( parsedContent ) ;
138
+
139
+ if ( ! validationResult . success ) {
140
+ console . error ( 'OpenAI edit response validation (initial) failed:' , validationResult . error . flatten ( ) ) ;
141
+ const errorMessages = validationResult . error . errors . map ( err => `${ err . path . join ( '.' ) } - ${ err . message } ` ) . join ( '; ' ) ;
142
+ throw new Error ( `Generated edit data is not in the expected format: ${ errorMessages } ` ) ;
143
+ }
144
+
145
+ // Manually validate the refinement for update operations
146
+ const validatedSuggestions : AICardEditSuggestion [ ] = [ ] ;
147
+ for ( const edit of validationResult . data . edits ) {
148
+ if ( edit . type === 'update' ) {
149
+ if ( ! edit . front_text && ! edit . back_text && ! edit . extra_context ) {
150
+ throw new Error ( `Update operation for cardId ${ edit . cardId } must include at least one field to change (front_text, back_text, or extra_context).` ) ;
151
+ }
152
+ }
153
+ validatedSuggestions . push ( edit as AICardEditSuggestion ) ; // Cast after validation
154
+ }
155
+
156
+ return { success : true , suggestions : validatedSuggestions } ;
157
+
158
+ } catch ( error ) {
159
+ console . error ( '[Get AI Edit Suggestions Action Error]' , error ) ;
160
+ const message = error instanceof Error ? error . message : 'Failed to get AI edit suggestions.' ;
161
+ return { success : false , message } ;
162
+ }
163
+ }
164
+
165
+ // Placeholder for applyAIEditsAction - to be implemented next
166
+ interface ApplyAIEditsResult {
167
+ success : boolean ;
168
+ appliedCount : number ;
169
+ failedCount : number ;
170
+ // Optionally, details about failures
171
+ failureDetails ?: { edit : AICardEditSuggestion , message : string } [ ] ;
172
+ message ?: string ;
173
+ }
174
+
175
+ export async function applyAIEditsAction (
176
+ deckId : string ,
177
+ edits : AICardEditSuggestion [ ] ,
178
+ token : string | undefined
179
+ ) : Promise < ApplyAIEditsResult > {
180
+ const authResult = verifyAuthToken ( token ) ;
181
+ if ( ! authResult ) {
182
+ return { success : false , appliedCount : 0 , failedCount : edits . length , message : 'Unauthorized.' } ;
183
+ }
184
+
185
+ if ( ! deckId || ! ObjectId . isValid ( deckId ) ) {
186
+ return { success : false , appliedCount : 0 , failedCount : edits . length , message : 'Invalid Deck ID.' } ;
187
+ }
188
+
189
+ let appliedCount = 0 ;
190
+ const failureDetails : { edit : AICardEditSuggestion , message : string } [ ] = [ ] ;
191
+
192
+ // Import card actions here to avoid circular dependency issues at module level if they also import from here
193
+ const { createCardAction, updateCardAction, deleteCardAction } = await import ( './cards' ) ;
194
+
195
+ for ( const edit of edits ) {
196
+ try {
197
+ let result : { success : boolean ; message ?: string ; card ?: any } ;
198
+ switch ( edit . type ) {
199
+ case 'create' :
200
+ result = await createCardAction ( {
201
+ deckId,
202
+ frontText : edit . front_text ,
203
+ backText : edit . back_text ,
204
+ // extra_context is not directly supported by createCardAction, handle if needed
205
+ // or update createCardAction to accept it.
206
+ // For now, it will be ignored for 'create' via this path.
207
+ token,
208
+ } ) ;
209
+ break ;
210
+ case 'update' :
211
+ // Ensure at least one field is being updated (already validated by getAIEditSuggestionsAction)
212
+ result = await updateCardAction ( {
213
+ cardId : edit . cardId ,
214
+ deckId, // updateCardAction needs deckId for revalidation context
215
+ frontText : edit . front_text ,
216
+ backText : edit . back_text ,
217
+ // extra_context is not directly supported by updateCardAction.
218
+ // If we want to update it, updateCardAction must be modified.
219
+ token,
220
+ } ) ;
221
+ break ;
222
+ case 'delete' :
223
+ result = await deleteCardAction ( {
224
+ cardId : edit . cardId ,
225
+ deckId, // deleteCardAction needs deckId
226
+ token,
227
+ } ) ;
228
+ break ;
229
+ default :
230
+ // Should not happen due to Zod validation
231
+ throw new Error ( 'Invalid edit type encountered.' ) ;
232
+ }
233
+
234
+ if ( result . success ) {
235
+ appliedCount ++ ;
236
+ } else {
237
+ failureDetails . push ( { edit, message : result . message || 'Unknown error during edit application.' } ) ;
238
+ }
239
+ } catch ( error ) {
240
+ const message = error instanceof Error ? error . message : 'Unknown exception during edit application.' ;
241
+ failureDetails . push ( { edit, message } ) ;
242
+ }
243
+ }
244
+
245
+ // Revalidate the deck edit page path after all edits
246
+ if ( appliedCount > 0 || failureDetails . length > 0 ) { // Revalidate if any change or attempted change
247
+ const { revalidatePath } = await import ( 'next/cache' ) ;
248
+ revalidatePath ( `/deck/${ deckId } /edit` ) ;
249
+ revalidatePath ( `/deck/${ deckId } /overview` ) ; // Also overview if card counts change
250
+ }
251
+
252
+ if ( failureDetails . length > 0 ) {
253
+ return {
254
+ success : false , // Overall success is false if any edit failed
255
+ appliedCount,
256
+ failedCount : failureDetails . length ,
257
+ failureDetails,
258
+ message : `${ failureDetails . length } edit(s) failed to apply.`
259
+ } ;
260
+ }
261
+
262
+ return {
263
+ success : true ,
264
+ appliedCount,
265
+ failedCount : 0 ,
266
+ message : `${ appliedCount } edit(s) applied successfully.`
267
+ } ;
268
+ }
0 commit comments