Skip to content

Commit 021bb91

Browse files
feat(Grade By Step): Summarize student work using AI (#2266)
Co-authored-by: Jonathan Lim-Breitbart <breity10@gmail.com>
1 parent 26269e7 commit 021bb91

19 files changed

Lines changed: 1088 additions & 124 deletions

package-lock.json

Lines changed: 239 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/chatbot/chat.service.ts

Lines changed: 0 additions & 55 deletions
This file was deleted.

src/app/chatbot/chatbot.component.spec.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
import { ComponentFixture, TestBed } from '@angular/core/testing';
22
import { ChatbotComponent } from './chatbot.component';
33
import { ChatbotService } from './chatbot.service';
4-
import { AwsBedRockService } from './awsBedRock.service';
54
import { ConfigService } from '../../assets/wise5/services/configService';
65
import { DataService } from '../services/data.service';
76
import { ProjectService } from '../../assets/wise5/services/projectService';
87
import { BreakpointObserver } from '@angular/cdk/layout';
98
import { of, throwError } from 'rxjs';
109
import { Chat, ChatMessage } from './chat';
1110
import { provideHttpClient } from '@angular/common/http';
11+
import { OpenAiChatService } from '../services/chat/openAiChat.service';
1212

1313
describe('ChatbotComponent', () => {
1414
let component: ChatbotComponent;
1515
let fixture: ComponentFixture<ChatbotComponent>;
1616
let chatbotService: jasmine.SpyObj<ChatbotService>;
17-
let awsBedRockService: jasmine.SpyObj<AwsBedRockService>;
17+
let chatService: jasmine.SpyObj<OpenAiChatService>;
1818
let configService: jasmine.SpyObj<ConfigService>;
1919
let dataService: jasmine.SpyObj<DataService>;
2020
let projectService: jasmine.SpyObj<ProjectService>;
@@ -41,7 +41,7 @@ describe('ChatbotComponent', () => {
4141
'updateChat',
4242
'deleteChat'
4343
]);
44-
const awsBedRockServiceSpy = jasmine.createSpyObj('AwsBedRockService', [
44+
const chatServiceSpy = jasmine.createSpyObj('OpenAiChatService', [
4545
'sendMessage',
4646
'generateChatTitle'
4747
]);
@@ -66,7 +66,7 @@ describe('ChatbotComponent', () => {
6666
imports: [ChatbotComponent],
6767
providers: [
6868
{ provide: ChatbotService, useValue: chatbotServiceSpy },
69-
{ provide: AwsBedRockService, useValue: awsBedRockServiceSpy },
69+
{ provide: OpenAiChatService, useValue: chatServiceSpy },
7070
{ provide: ConfigService, useValue: configServiceSpy },
7171
{ provide: DataService, useValue: dataServiceSpy },
7272
{ provide: ProjectService, useValue: projectServiceSpy },
@@ -76,7 +76,7 @@ describe('ChatbotComponent', () => {
7676
}).compileComponents();
7777

7878
chatbotService = TestBed.inject(ChatbotService) as jasmine.SpyObj<ChatbotService>;
79-
awsBedRockService = TestBed.inject(AwsBedRockService) as jasmine.SpyObj<AwsBedRockService>;
79+
chatService = TestBed.inject(OpenAiChatService) as jasmine.SpyObj<OpenAiChatService>;
8080
configService = TestBed.inject(ConfigService) as jasmine.SpyObj<ConfigService>;
8181
dataService = TestBed.inject(DataService) as jasmine.SpyObj<DataService>;
8282
projectService = TestBed.inject(ProjectService) as jasmine.SpyObj<ProjectService>;
@@ -134,8 +134,8 @@ describe('ChatbotComponent', () => {
134134
const assistantResponse = 'Hi there!';
135135
component['userInput'] = userMessage;
136136

137-
awsBedRockService.sendMessage.and.returnValue(Promise.resolve(assistantResponse));
138-
awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve('New Title'));
137+
chatService.sendMessage.and.returnValue(Promise.resolve(assistantResponse));
138+
spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve('New Title'));
139139
await component['sendMessage']();
140140

141141
expect(component['messages'].length).toBe(2);
@@ -156,12 +156,12 @@ describe('ChatbotComponent', () => {
156156

157157
// First user message (only system message exists initially)
158158
component['messages'] = [];
159-
awsBedRockService.sendMessage.and.returnValue(Promise.resolve(assistantResponse));
160-
awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve(newTitle));
159+
chatService.sendMessage.and.returnValue(Promise.resolve(assistantResponse));
160+
spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve(newTitle));
161161

162162
await component['sendMessage']();
163163

164-
expect(awsBedRockService.generateChatTitle).toHaveBeenCalledWith(userMessage);
164+
expect(component['generateChatTitle']).toHaveBeenCalledWith(userMessage);
165165
expect(component['currentChat']?.title).toBe(newTitle);
166166
expect(chatbotService.updateChat).toHaveBeenCalled();
167167
});
@@ -171,7 +171,7 @@ describe('ChatbotComponent', () => {
171171

172172
await component['sendMessage']();
173173

174-
expect(awsBedRockService.sendMessage).not.toHaveBeenCalled();
174+
expect(chatService.sendMessage).not.toHaveBeenCalled();
175175
expect(component['messages'].length).toBe(0);
176176
});
177177

@@ -181,7 +181,7 @@ describe('ChatbotComponent', () => {
181181

182182
await component['sendMessage']();
183183

184-
expect(awsBedRockService.sendMessage).not.toHaveBeenCalled();
184+
expect(chatService.sendMessage).not.toHaveBeenCalled();
185185
});
186186

187187
it('should not send messages when no current chat', async () => {
@@ -190,12 +190,12 @@ describe('ChatbotComponent', () => {
190190

191191
await component['sendMessage']();
192192

193-
expect(awsBedRockService.sendMessage).not.toHaveBeenCalled();
193+
expect(chatService.sendMessage).not.toHaveBeenCalled();
194194
});
195195

196196
it('should handle errors when sending messages', async () => {
197197
component['userInput'] = 'Hello';
198-
awsBedRockService.sendMessage.and.returnValue(Promise.reject(new Error('API error')));
198+
chatService.sendMessage.and.returnValue(Promise.reject(new Error('API error')));
199199

200200
await component['sendMessage']();
201201

@@ -336,8 +336,8 @@ describe('ChatbotComponent', () => {
336336

337337
it('should send message on Enter key press', () => {
338338
component['userInput'] = 'Hello';
339-
awsBedRockService.sendMessage.and.returnValue(Promise.resolve('Response'));
340-
awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve('New Title'));
339+
chatService.sendMessage.and.returnValue(Promise.resolve('Response'));
340+
spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve('New Title'));
341341

342342
const event = new KeyboardEvent('keypress', { key: 'Enter' });
343343
spyOn(event, 'preventDefault');
@@ -356,7 +356,7 @@ describe('ChatbotComponent', () => {
356356
component['handleKeyPress'](event);
357357

358358
expect(event.preventDefault).not.toHaveBeenCalled();
359-
expect(awsBedRockService.sendMessage).not.toHaveBeenCalled();
359+
expect(chatService.sendMessage).not.toHaveBeenCalled();
360360
});
361361
});
362362

src/app/chatbot/chatbot.component.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,16 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
1010
import { MatTooltipModule } from '@angular/material/tooltip';
1111
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
1212
import { BreakpointObserver } from '@angular/cdk/layout';
13-
import { skip, Subscription } from 'rxjs';
13+
import { Subscription } from 'rxjs';
1414
import { ChatbotService } from './chatbot.service';
1515
import { ConfigService } from '../../assets/wise5/services/configService';
1616
import { DataService } from '../services/data.service';
1717
import { Chat, ChatMessage } from './chat';
18-
import { AwsBedRockService } from './awsBedRock.service';
1918
import { ProjectService } from '../../assets/wise5/services/projectService';
2019
import { MarkdownComponent } from 'ngx-markdown';
2120
import { ChatHistoryDialogComponent } from './chat-history-dialog.component';
22-
import { MatDividerModule } from '@angular/material/divider';
21+
import { ChatService } from '../services/chat/chat.service';
22+
import { OpenAiChatService } from '../services/chat/openAiChat.service';
2323

2424
@Component({
2525
imports: [
@@ -42,7 +42,7 @@ import { MatDividerModule } from '@angular/material/divider';
4242
export class ChatbotComponent {
4343
private breakpointObserver = inject(BreakpointObserver);
4444
private chatbotService: ChatbotService = inject(ChatbotService);
45-
private awsBedRockService: AwsBedRockService = inject(AwsBedRockService);
45+
private chatService: ChatService = inject(OpenAiChatService);
4646
private configService: ConfigService = inject(ConfigService);
4747
private dataService: DataService = inject(DataService);
4848
private projectService = inject(ProjectService);
@@ -112,7 +112,7 @@ export class ChatbotComponent {
112112
this.loading = true;
113113
this.scrollToBottom();
114114
try {
115-
const response = await this.awsBedRockService.sendMessage(this.messages);
115+
const response = await this.chatService.sendMessage(this.messages);
116116
this.messages.push(
117117
new ChatMessage('assistant', response, this.dataService.getCurrentNode().id)
118118
);
@@ -154,7 +154,7 @@ export class ChatbotComponent {
154154
*/
155155
private async generateAndSetChatTitle(firstUserMessage: ChatMessage): Promise<void> {
156156
try {
157-
let newTitle = await this.awsBedRockService.generateChatTitle(firstUserMessage.content);
157+
let newTitle = await this.generateChatTitle(firstUserMessage.content);
158158
// Remove surrounding quotes if any
159159
newTitle = newTitle.replace(/^["'](.*)["']$/, '$1').trim();
160160
if (newTitle) {
@@ -165,6 +165,24 @@ export class ChatbotComponent {
165165
}
166166
}
167167

168+
/**
169+
* Generates a short, concise title for a chat based on the first message.
170+
* @param message The first user message content.
171+
* @returns A promise that resolves to the generated title.
172+
*/
173+
async generateChatTitle(message: string): Promise<string> {
174+
const prompt = `Generate a short, concise title (max 5 words) for a chat that starts with this message: "${message}". Respond only with the title, no quotes or extra text. If the language of the message is not English, return the title in that language.`;
175+
const messages: ChatMessage[] = [
176+
new ChatMessage(
177+
'system',
178+
'You are a helpful assistant that generates short titles for chat conversations.',
179+
''
180+
),
181+
new ChatMessage('user', prompt, '')
182+
];
183+
return this.chatService.sendMessage(messages);
184+
}
185+
168186
protected switchToChat(chat: Chat): void {
169187
this.currentChat = chat;
170188
this.messages = [...chat.messages];

src/app/chatbot/awsBedRock.service.ts renamed to src/app/services/chat/awsBedRockChat.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
22
import { ChatService } from './chat.service';
33

44
@Injectable({ providedIn: 'root' })
5-
export class AwsBedRockService extends ChatService {
5+
export class AwsBedRockChatService extends ChatService {
66
protected chatEndpoint = '/api/aws-bedrock/chat';
77
protected model: string = 'google.gemma-3-27b-it';
88

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { inject } from '@angular/core';
2+
import { ChatMessage } from '../../chatbot/chat';
3+
import { firstValueFrom } from 'rxjs';
4+
import { HttpClient } from '@angular/common/http';
5+
6+
export abstract class ChatService {
7+
protected abstract chatEndpoint: string;
8+
protected abstract model: string;
9+
10+
private http = inject(HttpClient);
11+
12+
/**
13+
* Sends a message to the chat endpoint.
14+
* @param messages The conversation history.
15+
* @returns A promise that resolves to the response from the chat endpoint.
16+
*/
17+
async sendMessage(messages: ChatMessage[]): Promise<string> {
18+
const payload = {
19+
messages: messages.map((msg) => ({
20+
role: msg.role,
21+
content: msg.content
22+
})),
23+
model: this.model
24+
};
25+
try {
26+
const response = await firstValueFrom(this.http.post<any>(`${this.chatEndpoint}`, payload));
27+
return this.processResponse(response.choices[0].message.content);
28+
} catch (error) {
29+
console.error('Error calling chat endpoint:', error);
30+
throw error;
31+
}
32+
}
33+
34+
processResponse(response: string): string {
35+
return response;
36+
}
37+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Injectable } from '@angular/core';
2+
import { ChatService } from './chat.service';
3+
4+
@Injectable({ providedIn: 'root' })
5+
export class OpenAiChatService extends ChatService {
6+
protected chatEndpoint = '/api/chat-gpt';
7+
protected model: string = 'gpt-4o';
8+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Injectable } from '@angular/core';
2+
3+
@Injectable({
4+
providedIn: 'root'
5+
})
6+
export class LocalStorageService {
7+
setItem(key: string, value: any): void {
8+
try {
9+
localStorage.setItem(key, JSON.stringify(value));
10+
} catch (e) {
11+
console.error('Error saving to local storage', e);
12+
}
13+
}
14+
15+
getItem(key: string): any {
16+
try {
17+
const item = localStorage.getItem(key);
18+
return item ? JSON.parse(item) : null;
19+
} catch (e) {
20+
console.error('Error reading from local storage', e);
21+
return null;
22+
}
23+
}
24+
25+
removeItem(key: string): void {
26+
try {
27+
localStorage.removeItem(key);
28+
} catch (e) {
29+
console.error('Error removing from local storage', e);
30+
}
31+
}
32+
33+
clear(): void {
34+
try {
35+
localStorage.clear();
36+
} catch (e) {
37+
console.error('Error clearing local storage', e);
38+
}
39+
}
40+
}

0 commit comments

Comments
 (0)