From 1e1682fa6a3f70c86e36e9385e8d396d554c2409 Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Tue, 27 Jan 2026 13:49:19 -0800 Subject: [PATCH 01/17] feat(OpenResponse): Summarize student responses in grading tool (#2259) Co-authored-by: Jonathan Lim-Breitbart --- src/app/services/localStorageService.ts | 40 ++ .../component-summary.component.html | 12 + .../component-summary.component.spec.ts | 2 + .../component-summary.component.ts | 7 + ...en-response-summary-display.component.html | 24 ++ ...response-summary-display.component.spec.ts | 377 ++++++++++++++++++ ...open-response-summary-display.component.ts | 98 +++++ src/messages.xlf | 28 ++ 8 files changed, 588 insertions(+) create mode 100644 src/app/services/localStorageService.ts create mode 100644 src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html create mode 100644 src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts create mode 100644 src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts diff --git a/src/app/services/localStorageService.ts b/src/app/services/localStorageService.ts new file mode 100644 index 00000000000..08f2cf8926e --- /dev/null +++ b/src/app/services/localStorageService.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class LocalStorageService { + setItem(key: string, value: any): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error('Error saving to local storage', e); + } + } + + getItem(key: string): any { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : null; + } catch (e) { + console.error('Error reading from local storage', e); + return null; + } + } + + removeItem(key: string): void { + try { + localStorage.removeItem(key); + } catch (e) { + console.error('Error removing from local storage', e); + } + } + + clear(): void { + try { + localStorage.clear(); + } catch (e) { + console.error('Error clearing local storage', e); + } + } +} diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html index dffde8abbd6..f7c1a5d0092 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html @@ -72,6 +72,18 @@ /> + } @else if (component?.type === 'OpenResponse') { + + + + + } } diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.spec.ts b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.spec.ts index 3efa31d2f3a..7d2349d79a5 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.spec.ts +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.spec.ts @@ -12,6 +12,7 @@ import { CRaterService } from '../../../services/cRaterService'; import { TeacherDataService } from '../../../services/teacherDataService'; import { SummaryService } from '../../../components/summary/summaryService'; import { PeerGroupButtonComponent } from '../peer-group-button/peer-group-button.component'; +import { ProjectService } from '../../../services/projectService'; let component: ComponentSummaryComponent; let fixture: ComponentFixture; @@ -31,6 +32,7 @@ describe('ComponentSummaryComponent', () => { AnnotationService, ComponentServiceLookupService, CRaterService, + ProjectService, SummaryService ), MockProvider(TeacherDataService, { diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts index 2fa2c1ff40b..7c8d98686f8 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts @@ -14,6 +14,8 @@ import { IdeasSummaryComponent } from '../../../directives/teacher-summary-displ import { MatchSummaryDisplayComponent } from '../../../directives/teacher-summary-display/match-summary-display/match-summary-display.component'; import { MatCardModule } from '@angular/material/card'; import { CRaterService } from '../../../services/cRaterService'; +import { OpenResponseSummaryDisplayComponent } from '../../../directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component'; +import { ProjectService } from '../../../services/projectService'; @Component({ imports: [ @@ -22,6 +24,7 @@ import { CRaterService } from '../../../services/cRaterService'; MatCardModule, MatchSummaryDisplayComponent, MilestoneReportButtonComponent, + OpenResponseSummaryDisplayComponent, PeerGroupButtonComponent, TeacherSummaryDisplayComponent ], @@ -48,6 +51,7 @@ export class ComponentSummaryComponent { private componentServiceLookupService: ComponentServiceLookupService, private cRaterService: CRaterService, private dataService: TeacherDataService, + private projectService: ProjectService, private summaryService: SummaryService ) {} @@ -79,6 +83,9 @@ export class ComponentSummaryComponent { (this.hasScoresSummary && this.hasScoreAnnotation) || this.hasIdeaRubricData || this.component?.type === 'Match'; + if (this.component?.type === 'OpenResponse') { + this.hasSummaryData = this.projectService.getProject().ai?.enabled; + } } private setSource(): void { diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html new file mode 100644 index 00000000000..f4bda35f709 --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html @@ -0,0 +1,24 @@ +@if (hasStudentResponses) { +
+
+ + @if (generatingSummary) { + + } +
+ @if (newSummaryAvailable) { + *New responses since last summary + } +
+ @if (summary) { + +
+ Summary generated {{ summaryDate | date: 'short' }} from + {{ getLatestPeriodComponentStates().length }} responses +
+ } +} @else { +
No student responses
+} diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts new file mode 100644 index 00000000000..65290c15839 --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts @@ -0,0 +1,377 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OpenResponseSummaryDisplayComponent } from './open-response-summary-display.component'; +import { MockComponent, MockProviders } from 'ng-mocks'; +import { AnnotationService } from '../../../services/annotationService'; +import { ConfigService } from '../../../services/configService'; +import { CRaterService } from '../../../services/cRaterService'; +import { ProjectService } from '../../../services/projectService'; +import { SummaryService } from '../../../components/summary/summaryService'; +import { AwsBedRockService } from '../../../../../app/chatbot/awsBedRock.service'; +import { TeacherDataService } from '../../../services/teacherDataService'; +import { LocalStorageService } from '../../../../../app/services/localStorageService'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { DataService } from '../../../../../app/services/data.service'; +import { MarkdownComponent, MarkdownService } from 'ngx-markdown'; + +describe('OpenResponseSummaryDisplayComponent', () => { + let component: OpenResponseSummaryDisplayComponent; + let fixture: ComponentFixture; + let awsBedRockService: AwsBedRockService; + let localStorageService: LocalStorageService; + let dataService: TeacherDataService; + let projectService: ProjectService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [OpenResponseSummaryDisplayComponent, MockComponent(MarkdownComponent)], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: DataService, useExisting: TeacherDataService }, + MockProviders( + AnnotationService, + AwsBedRockService, + ConfigService, + CRaterService, + LocalStorageService, + MarkdownService, + ProjectService, + SummaryService, + TeacherDataService + ) + ] + }).compileComponents(); + + awsBedRockService = TestBed.inject(AwsBedRockService); + localStorageService = TestBed.inject(LocalStorageService); + dataService = TestBed.inject(TeacherDataService) as TeacherDataService; + projectService = TestBed.inject(ProjectService); + + spyOn(projectService, 'getComponent').and.returnValue({ + id: 'component1', + type: 'OpenResponse', + prompt: 'What is your opinion on climate change?' + } as any); + + fixture = TestBed.createComponent(OpenResponseSummaryDisplayComponent); + component = fixture.componentInstance; + component.nodeId = 'node1'; + component.componentId = 'component1'; + component.periodId = 1; + }); + + describe('ngOnInit', () => { + it('should call renderDisplay', () => { + spyOn(component as any, 'renderDisplay'); + component.ngOnInit(); + expect((component as any).renderDisplay).toHaveBeenCalled(); + }); + }); + + describe('renderDisplay', () => { + it('should set hasStudentResponses to false when no component states exist', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue([]); + component.ngOnInit(); + fixture.detectChanges(); + expect(component['hasStudentResponses']).toBe(false); + }); + + it('should set hasStudentResponses to true when component states exist', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + fixture.detectChanges(); + expect(component['hasStudentResponses']).toBe(true); + }); + + it('should load summary from localStorage if it exists', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const savedSummary = 'This is a saved summary'; + spyOn(localStorageService, 'getItem').and.returnValues(savedSummary, 1000); + fixture.detectChanges(); + expect(component['summary']).toBe(savedSummary); + }); + + it('should set newSummaryAvailable to true when responses are newer than summary', () => { + const componentStates = getComponentStates(); + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + const oldTimestamp = 1000; + spyOn(localStorageService, 'getItem').and.returnValues('Old summary', oldTimestamp); + fixture.detectChanges(); + expect(component['newSummaryAvailable']).toBe(true); + }); + + it('should set newSummaryAvailable to false when summary is newer than responses', () => { + const componentStates = getComponentStates(); + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + const futureTimestamp = Date.now() + 100000; + spyOn(localStorageService, 'getItem').and.returnValues('Recent summary', futureTimestamp); + component.ngOnInit(); + fixture.detectChanges(); + expect(component['newSummaryAvailable']).toBe(false); + }); + }); + + describe('getLatestPeriodComponentStates', () => { + it('should filter component states by period ID', () => { + const componentStates = getComponentStates(); + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + component.periodId = 1; + const result = component['getLatestPeriodComponentStates'](); + expect(result.every((state) => state.periodId === 1)).toBe(true); + }); + + it('should return all component states when periodId is -1', () => { + const componentStates = [ + ...getComponentStates(), + { + id: 4, + componentId: 'component1', + nodeId: 'node1', + periodId: 2, + runId: 1, + serverSaveTime: 4000, + studentData: { response: 'Response from period 2' }, + workgroupId: 4 + } + ]; + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + component.periodId = -1; + const result = component['getLatestPeriodComponentStates'](); + expect(result.length).toBe(4); + }); + + it('should return only the latest state per workgroup', () => { + const componentStates = [ + ...getComponentStates(), + { + id: 4, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 5000, + studentData: { response: 'Updated response from workgroup 1' }, + workgroupId: 1 + } + ]; + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + component.periodId = 1; + const result = component['getLatestPeriodComponentStates'](); + const workgroup1States = result.filter((state) => state.workgroupId === 1); + expect(workgroup1States.length).toBe(1); + expect(workgroup1States[0].serverSaveTime).toBe(5000); + }); + }); + + describe('generateSummary', () => { + beforeEach(() => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should call awsBedRockService with correct system prompt', async () => { + const sendMessageSpy = spyOn(awsBedRockService, 'sendMessage').and.returnValue( + Promise.resolve('Generated summary') + ); + await component['generateSummary'](); + const messages = sendMessageSpy.calls.mostRecent().args[0]; + expect(messages[0].role).toBe('system'); + expect(messages[0].content).toContain('What is your opinion on climate change?'); + }); + + it('should call awsBedRockService with student responses', async () => { + const sendMessageSpy = spyOn(awsBedRockService, 'sendMessage').and.returnValue( + Promise.resolve('Generated summary') + ); + await component['generateSummary'](); + const messages = sendMessageSpy.calls.mostRecent().args[0]; + expect(messages[1].role).toBe('user'); + expect(messages[1].content).toContain(''); + expect(messages[1].content).toContain('Climate change is real'); + }); + + it('should save summary to localStorage', async () => { + const generatedSummary = 'This is a generated summary'; + spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve(generatedSummary)); + const setItemSpy = spyOn(localStorageService, 'setItem'); + await component['generateSummary'](); + expect(setItemSpy).toHaveBeenCalledWith( + 'openResponseSummary-1-node1-component1', + generatedSummary + ); + }); + + it('should save timestamp to localStorage', async () => { + spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + const setItemSpy = spyOn(localStorageService, 'setItem'); + const beforeTime = new Date().getTime(); + await component['generateSummary'](); + const afterTime = new Date().getTime(); + const timestampCall = setItemSpy.calls + .all() + .find((call) => call.args[0].includes('timestamp')); + expect(timestampCall).toBeDefined(); + expect(timestampCall.args[1]).toBeGreaterThanOrEqual(beforeTime); + expect(timestampCall.args[1]).toBeLessThanOrEqual(afterTime); + }); + + it('should set generatingSummary to false after completion', async () => { + spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + await component['generateSummary'](); + expect(component['generatingSummary']).toBe(false); + }); + + it('should set newSummaryAvailable to false after generation', async () => { + component['newSummaryAvailable'] = true; + spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + await component['generateSummary'](); + expect(component['newSummaryAvailable']).toBe(false); + }); + + it('should update summary property', async () => { + const generatedSummary = 'This is a generated summary'; + spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve(generatedSummary)); + await component['generateSummary'](); + expect(component['summary']).toBe(generatedSummary); + }); + }); + + describe('getStudentResponses', () => { + it('should format student responses with XML tags', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + const responses = component['getStudentResponses'](); + expect(responses).toContain('Climate change is real'); + expect(responses).toContain('We need to act now'); + expect(responses).toContain('Renewable energy is the future'); + }); + + it('should concatenate all responses', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + const responses = component['getStudentResponses'](); + const responseCount = (responses.match(//g) || []).length; + expect(responseCount).toBe(3); + }); + }); + + describe('template rendering', () => { + it('should display "No student responses" when hasStudentResponses is false', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue([]); + component.ngOnInit(); + fixture.detectChanges(); + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('No student responses'); + }); + + it('should display generate button when hasStudentResponses is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector('button'); + expect(button).toBeTruthy(); + expect(button.textContent).toContain('Generate Class Summary'); + }); + + it('should disable generate button when generatingSummary is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + component['generatingSummary'] = true; + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector('button'); + expect(button.disabled).toBe(true); + }); + + it('should display "New responses since last summary" when newSummaryAvailable is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const oldTimestamp = 1000; + spyOn(localStorageService, 'getItem') + .withArgs('openResponseSummary-1-node1-component1') + .and.returnValue('Old summary') + .withArgs('openResponseSummary-timestamp-1-node1-component1') + .and.returnValue(oldTimestamp); + component.ngOnInit(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('New responses since last summary'); + }); + + it('should display spinner when generatingSummary is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + component['generatingSummary'] = true; + fixture.detectChanges(); + const spinner = fixture.nativeElement.querySelector('mat-spinner'); + expect(spinner).toBeTruthy(); + }); + + it('should display summary when summary exists', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const savedSummary = 'This is a saved summary'; + spyOn(localStorageService, 'getItem') + .withArgs('openResponseSummary-1-node1-component1') + .and.returnValue(savedSummary) + .withArgs('openResponseSummary-timestamp-1-node1-component1') + .and.returnValue(Date.now() + 100000); + component.ngOnInit(); + fixture.detectChanges(); + const markdown = fixture.nativeElement.querySelector('markdown'); + expect(markdown).toBeTruthy(); + }); + + it('should display response count when summary exists', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const savedSummary = 'This is a saved summary'; + spyOn(localStorageService, 'getItem') + .withArgs('openResponseSummary-1-node1-component1') + .and.returnValue(savedSummary) + .withArgs('openResponseSummary-timestamp-1-node1-component1') + .and.returnValue(Date.now() + 100000); + component.ngOnInit(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('3 responses'); + }); + }); +}); + +function getComponentStates(): any[] { + return [ + { + id: 1, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 1000, + studentData: { + response: 'Climate change is real' + }, + workgroupId: 1 + }, + { + id: 2, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 2000, + studentData: { + response: 'We need to act now' + }, + workgroupId: 2 + }, + { + id: 3, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 3000, + studentData: { + response: 'Renewable energy is the future' + }, + workgroupId: 3 + } + ]; +} diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts new file mode 100644 index 00000000000..84c334adc63 --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts @@ -0,0 +1,98 @@ +import { DatePipe } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { TeacherSummaryDisplayComponent } from '../teacher-summary-display.component'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { AwsBedRockService } from '../../../../../app/chatbot/awsBedRock.service'; +import { ChatMessage } from '../../../../../app/chatbot/chat'; +import { TeacherDataService } from '../../../services/teacherDataService'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { LocalStorageService } from '../../../../../app/services/localStorageService'; +import { MarkdownComponent } from 'ngx-markdown'; + +@Component({ + imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], + selector: 'open-response-summary', + templateUrl: './open-response-summary-display.component.html' +}) +export class OpenResponseSummaryDisplayComponent extends TeacherSummaryDisplayComponent { + protected awsBedRockService: AwsBedRockService = inject(AwsBedRockService); + protected generatingSummary: boolean = false; + protected hasStudentResponses: boolean = false; + private localStorageService: LocalStorageService = inject(LocalStorageService); + protected newSummaryAvailable: boolean = false; + protected summary: string; + protected summaryDate: Date; + private summaryTimestamp: number; + + ngOnInit(): void { + this.renderDisplay(); + } + + protected renderDisplay(): void { + super.renderDisplay(); + const latestPeriodComponentStates = this.getLatestPeriodComponentStates(); + this.hasStudentResponses = latestPeriodComponentStates.length > 0; + if (!this.hasStudentResponses) { + return; + } + this.summary = + this.localStorageService.getItem( + `openResponseSummary-${this.periodId}-${this.nodeId}-${this.componentId}` + ) || ''; + this.summaryTimestamp = + this.localStorageService.getItem( + `openResponseSummary-timestamp-${this.periodId}-${this.nodeId}-${this.componentId}` + ) || 0; + this.summaryDate = new Date(this.summaryTimestamp); + const lastResponseTime = latestPeriodComponentStates.reduce((max, state) => { + return Math.max(max, state.serverSaveTime); + }, 0); + this.newSummaryAvailable = + this.summaryTimestamp > 0 && lastResponseTime > this.summaryTimestamp; + } + + protected getLatestPeriodComponentStates(): any[] { + return (this.dataService as TeacherDataService) + .getComponentStatesByComponentId(this.componentId) + .filter((state) => state.periodId === this.periodId || this.periodId === -1) + .sort((a, b) => a.serverSaveTime - b.serverSaveTime) + .reduceRight( + (soFar, currentState) => + soFar.find((state) => state.workgroupId === currentState.workgroupId) + ? soFar + : soFar.concat(currentState), + [] + ); + } + + protected async generateSummary(): Promise { + this.generatingSummary = true; + const prompt = this.projectService.getComponent(this.nodeId, this.componentId).prompt; + const systemPrompt = `You are a teacher who is summarizing student responses to the following question: "${prompt}". + Each student response is in the format: Response. + In the same language as the question, provide a summary of the responses in 100 words or less.`; + const messages = [ + new ChatMessage('system', systemPrompt, this.nodeId), + new ChatMessage('user', this.getStudentResponses(), this.nodeId) + ]; + this.summary = await this.awsBedRockService.sendMessage(messages); + this.localStorageService.setItem( + `openResponseSummary-${this.periodId}-${this.nodeId}-${this.componentId}`, + this.summary + ); + this.localStorageService.setItem( + `openResponseSummary-timestamp-${this.periodId}-${this.nodeId}-${this.componentId}`, + new Date().getTime() + ); + this.generatingSummary = false; + this.newSummaryAvailable = false; + } + + private getStudentResponses(): string { + return this.getLatestPeriodComponentStates().reduce( + (soFar, state) => `${soFar}${state.studentData.response}`, + '' + ); + } +} diff --git a/src/messages.xlf b/src/messages.xlf index 7ec3d98f169..f0bae20cbf5 100644 --- a/src/messages.xlf +++ b/src/messages.xlf @@ -21930,6 +21930,34 @@ If this problem continues, let your teacher know and move on to the next activit 58,61 + + Generate Class Summary + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 5,7 + + + + *New responses since last summary + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 12,16 + + + + Summary generated from responses + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 18,22 + + + + No student responses + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 23,25 + + The student will see a graph of their individual data here. From d99fdcca6c7b5f69a421b71b617cd18fa221265a Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Wed, 4 Feb 2026 10:12:59 -0800 Subject: [PATCH 02/17] feat(Discussion): Summarize student responses in grading tool (#2262) Co-authored-by: Jonathan Lim-Breitbart --- .../component-summary.component.html | 12 +++ .../component-summary.component.ts | 4 +- .../ai-summary-display.component.html | 24 +++++ .../ai-summary-display.component.ts | 78 ++++++++++++++++ .../discussion-summary-display.component.ts | 49 +++++++++++ src/messages.xlf | 88 +++++++++++++------ 6 files changed, 226 insertions(+), 29 deletions(-) create mode 100644 src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html create mode 100644 src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts create mode 100644 src/assets/wise5/directives/teacher-summary-display/discussion-summary-display/discussion-summary-display.component.ts diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html index f7c1a5d0092..02c58d4a888 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html @@ -84,6 +84,18 @@ /> + } @else if (component?.type === 'Discussion') { + + + + + } } diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts index 7c8d98686f8..81d4190fd44 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts @@ -16,10 +16,12 @@ import { MatCardModule } from '@angular/material/card'; import { CRaterService } from '../../../services/cRaterService'; import { OpenResponseSummaryDisplayComponent } from '../../../directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component'; import { ProjectService } from '../../../services/projectService'; +import { DiscussionSummaryDisplayComponent } from '../../../directives/teacher-summary-display/discussion-summary-display/discussion-summary-display.component'; @Component({ imports: [ ComponentCompletionComponent, + DiscussionSummaryDisplayComponent, IdeasSummaryComponent, MatCardModule, MatchSummaryDisplayComponent, @@ -83,7 +85,7 @@ export class ComponentSummaryComponent { (this.hasScoresSummary && this.hasScoreAnnotation) || this.hasIdeaRubricData || this.component?.type === 'Match'; - if (this.component?.type === 'OpenResponse') { + if (this.component?.type === 'OpenResponse' || this.component?.type === 'Discussion') { this.hasSummaryData = this.projectService.getProject().ai?.enabled; } } diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html b/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html new file mode 100644 index 00000000000..f4bda35f709 --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html @@ -0,0 +1,24 @@ +@if (hasStudentResponses) { +
+
+ + @if (generatingSummary) { + + } +
+ @if (newSummaryAvailable) { + *New responses since last summary + } +
+ @if (summary) { + +
+ Summary generated {{ summaryDate | date: 'short' }} from + {{ getLatestPeriodComponentStates().length }} responses +
+ } +} @else { +
No student responses
+} diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts b/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts new file mode 100644 index 00000000000..db65ccbc4ff --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts @@ -0,0 +1,78 @@ +import { Component, inject } from '@angular/core'; +import { TeacherSummaryDisplayComponent } from '../teacher-summary-display.component'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { AwsBedRockService } from '../../../../../app/chatbot/awsBedRock.service'; +import { ChatMessage } from '../../../../../app/chatbot/chat'; +import { TeacherDataService } from '../../../services/teacherDataService'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { LocalStorageService } from '../../../../../app/services/localStorageService'; +import { MarkdownComponent } from 'ngx-markdown'; +import { DatePipe } from '@angular/common'; + +@Component({ + imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], + templateUrl: './ai-summary-display.component.html' +}) +export abstract class AiSummaryDisplayComponent extends TeacherSummaryDisplayComponent { + protected awsBedRockService: AwsBedRockService = inject(AwsBedRockService); + protected generatingSummary: boolean = false; + protected hasStudentResponses: boolean = false; + private localStorageService: LocalStorageService = inject(LocalStorageService); + protected newSummaryAvailable: boolean = false; + protected summary: string; + private summaryTimestamp: number; + + ngOnInit(): void { + this.renderDisplay(); + } + + protected renderDisplay(): void { + super.renderDisplay(); + const latestPeriodComponentStates = this.getLatestPeriodComponentStates(); + this.hasStudentResponses = latestPeriodComponentStates.length > 0; + if (!this.hasStudentResponses) { + return; + } + this.summary = this.localStorageService.getItem(this.getSummaryKey()) || ''; + this.summaryTimestamp = this.localStorageService.getItem(this.getSummaryTimestampKey()) || 0; + const lastResponseTime = latestPeriodComponentStates.reduce( + (max, state) => Math.max(max, state.serverSaveTime), + 0 + ); + this.newSummaryAvailable = + this.summaryTimestamp > 0 && lastResponseTime > this.summaryTimestamp; + } + + protected getLatestPeriodComponentStates(): any[] { + return (this.dataService as TeacherDataService) + .getComponentStatesByComponentId(this.componentId) + .filter((state) => state.periodId === this.periodId || this.periodId === -1) + .sort((a, b) => a.serverSaveTime - b.serverSaveTime); + } + + protected async generateSummary(): Promise { + this.generatingSummary = true; + const prompt = this.projectService.getComponent(this.nodeId, this.componentId).prompt; + this.summary = await this.awsBedRockService.sendMessage([ + new ChatMessage('system', this.getSystemPrompt(prompt), this.nodeId), + new ChatMessage('user', this.getStudentResponses(), this.nodeId) + ]); + this.localStorageService.setItem(this.getSummaryKey(), this.summary); + this.localStorageService.setItem(this.getSummaryTimestampKey(), new Date().getTime()); + this.generatingSummary = false; + this.newSummaryAvailable = false; + } + + protected abstract getStudentResponses(): string; + + protected abstract getSystemPrompt(prompt: string): string; + + private getSummaryKey(): string { + return `component-summary-${this.periodId}-${this.nodeId}-${this.componentId}`; + } + + private getSummaryTimestampKey(): string { + return `component-summary-timestamp-${this.periodId}-${this.nodeId}-${this.componentId}`; + } +} diff --git a/src/assets/wise5/directives/teacher-summary-display/discussion-summary-display/discussion-summary-display.component.ts b/src/assets/wise5/directives/teacher-summary-display/discussion-summary-display/discussion-summary-display.component.ts new file mode 100644 index 00000000000..b701a0c131a --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/discussion-summary-display/discussion-summary-display.component.ts @@ -0,0 +1,49 @@ +import { Component } from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MarkdownComponent } from 'ngx-markdown'; +import { AiSummaryDisplayComponent } from '../ai-summary-display/ai-summary-display.component'; +import { DatePipe } from '@angular/common'; + +interface Thread { + id: number; + post: string; + replies: string[]; +} + +@Component({ + imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], + selector: 'discussion-summary-display', + templateUrl: '../ai-summary-display/ai-summary-display.component.html' +}) +export class DiscussionSummaryDisplayComponent extends AiSummaryDisplayComponent { + protected getSystemPrompt(prompt: string): string { + return `You are a teacher who is summarizing students' discussion threads, which include posts and replies to the following question: "${prompt}". + Each thread is in the format: PostReply 1Reply 2. + In the same language as the question, provide a summary of the threads in 100 words or less.`; + } + + protected getStudentResponses(): string { + return this.getDiscussionThreads().reduce( + (soFar, thread) => + `${soFar}${thread.post}${thread.replies.map((reply) => `${reply}`).join('')}`, + '' + ); + } + + private getDiscussionThreads(): Thread[] { + const states = this.getLatestPeriodComponentStates(); + const threads = states + .filter((state) => state.studentData.componentStateIdReplyingTo == null) + .map((post) => ({ id: post.id, post: post.studentData.response, replies: [] })); + states + .filter((state) => state.studentData.componentStateIdReplyingTo != null) + .forEach((reply) => { + threads + .find((t) => t.id === reply.studentData.componentStateIdReplyingTo) + ?.replies.push(reply.studentData.response); + }); + return threads; + } +} diff --git a/src/messages.xlf b/src/messages.xlf index f0bae20cbf5..89c84a09e92 100644 --- a/src/messages.xlf +++ b/src/messages.xlf @@ -21846,6 +21846,66 @@ If this problem continues, let your teacher know and move on to the next activit 401
+ + Generate Class Summary + + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + 5,7 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + 5,7 + + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 5,7 + + + + *New responses since last summary + + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + 12,16 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + 12,16 + + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 12,16 + + + + Summary generated from responses + + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + 18,22 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + 18,22 + + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 18,22 + + + + No student responses + + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + 23,25 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + 23,25 + + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 23,25 + + Student Ideas Detected @@ -21930,34 +21990,6 @@ If this problem continues, let your teacher know and move on to the next activit 58,61 - - Generate Class Summary - - src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html - 5,7 - - - - *New responses since last summary - - src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html - 12,16 - - - - Summary generated from responses - - src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html - 18,22 - - - - No student responses - - src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html - 23,25 - - The student will see a graph of their individual data here. From e098623e9e0c03d11d75d45b87a84018c6cfa0fc Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Wed, 4 Feb 2026 11:54:40 -0800 Subject: [PATCH 03/17] refactor: OpenResponseSummaryDisplay now extends AiSummaryDisplay --- .../ai-summary-display.component.ts | 12 +-- ...en-response-summary-display.component.html | 24 ------ ...response-summary-display.component.spec.ts | 14 ++-- ...open-response-summary-display.component.ts | 83 +++---------------- src/messages.xlf | 8 +- 5 files changed, 31 insertions(+), 110 deletions(-) delete mode 100644 src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts b/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts index db65ccbc4ff..f737e49d68f 100644 --- a/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts @@ -21,7 +21,7 @@ export abstract class AiSummaryDisplayComponent extends TeacherSummaryDisplayCom private localStorageService: LocalStorageService = inject(LocalStorageService); protected newSummaryAvailable: boolean = false; protected summary: string; - private summaryTimestamp: number; + protected summaryDate: Date; ngOnInit(): void { this.renderDisplay(); @@ -35,13 +35,13 @@ export abstract class AiSummaryDisplayComponent extends TeacherSummaryDisplayCom return; } this.summary = this.localStorageService.getItem(this.getSummaryKey()) || ''; - this.summaryTimestamp = this.localStorageService.getItem(this.getSummaryTimestampKey()) || 0; + const summaryTime = this.localStorageService.getItem(this.getSummaryTimestampKey()) || 0; + this.summaryDate = new Date(summaryTime); const lastResponseTime = latestPeriodComponentStates.reduce( (max, state) => Math.max(max, state.serverSaveTime), 0 ); - this.newSummaryAvailable = - this.summaryTimestamp > 0 && lastResponseTime > this.summaryTimestamp; + this.newSummaryAvailable = summaryTime > 0 && lastResponseTime > summaryTime; } protected getLatestPeriodComponentStates(): any[] { @@ -59,7 +59,9 @@ export abstract class AiSummaryDisplayComponent extends TeacherSummaryDisplayCom new ChatMessage('user', this.getStudentResponses(), this.nodeId) ]); this.localStorageService.setItem(this.getSummaryKey(), this.summary); - this.localStorageService.setItem(this.getSummaryTimestampKey(), new Date().getTime()); + const summaryTime = new Date().getTime(); + this.localStorageService.setItem(this.getSummaryTimestampKey(), summaryTime); + this.summaryDate = new Date(summaryTime); this.generatingSummary = false; this.newSummaryAvailable = false; } diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html deleted file mode 100644 index f4bda35f709..00000000000 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html +++ /dev/null @@ -1,24 +0,0 @@ -@if (hasStudentResponses) { -
-
- - @if (generatingSummary) { - - } -
- @if (newSummaryAvailable) { - *New responses since last summary - } -
- @if (summary) { - -
- Summary generated {{ summaryDate | date: 'short' }} from - {{ getLatestPeriodComponentStates().length }} responses -
- } -} @else { -
No student responses
-} diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts index 65290c15839..27b5797ea79 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts @@ -198,7 +198,7 @@ describe('OpenResponseSummaryDisplayComponent', () => { const setItemSpy = spyOn(localStorageService, 'setItem'); await component['generateSummary'](); expect(setItemSpy).toHaveBeenCalledWith( - 'openResponseSummary-1-node1-component1', + 'component-summary-1-node1-component1', generatedSummary ); }); @@ -288,9 +288,9 @@ describe('OpenResponseSummaryDisplayComponent', () => { spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); const oldTimestamp = 1000; spyOn(localStorageService, 'getItem') - .withArgs('openResponseSummary-1-node1-component1') + .withArgs('component-summary-1-node1-component1') .and.returnValue('Old summary') - .withArgs('openResponseSummary-timestamp-1-node1-component1') + .withArgs('component-summary-timestamp-1-node1-component1') .and.returnValue(oldTimestamp); component.ngOnInit(); fixture.detectChanges(); @@ -310,9 +310,9 @@ describe('OpenResponseSummaryDisplayComponent', () => { spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); const savedSummary = 'This is a saved summary'; spyOn(localStorageService, 'getItem') - .withArgs('openResponseSummary-1-node1-component1') + .withArgs('component-summary-1-node1-component1') .and.returnValue(savedSummary) - .withArgs('openResponseSummary-timestamp-1-node1-component1') + .withArgs('component-summary-timestamp-1-node1-component1') .and.returnValue(Date.now() + 100000); component.ngOnInit(); fixture.detectChanges(); @@ -324,9 +324,9 @@ describe('OpenResponseSummaryDisplayComponent', () => { spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); const savedSummary = 'This is a saved summary'; spyOn(localStorageService, 'getItem') - .withArgs('openResponseSummary-1-node1-component1') + .withArgs('component-summary-1-node1-component1') .and.returnValue(savedSummary) - .withArgs('openResponseSummary-timestamp-1-node1-component1') + .withArgs('component-summary-timestamp-1-node1-component1') .and.returnValue(Date.now() + 100000); component.ngOnInit(); fixture.detectChanges(); diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts index 84c334adc63..6d91bb38aca 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts @@ -1,57 +1,30 @@ import { DatePipe } from '@angular/common'; -import { Component, inject } from '@angular/core'; -import { TeacherSummaryDisplayComponent } from '../teacher-summary-display.component'; +import { Component } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; -import { AwsBedRockService } from '../../../../../app/chatbot/awsBedRock.service'; -import { ChatMessage } from '../../../../../app/chatbot/chat'; import { TeacherDataService } from '../../../services/teacherDataService'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; -import { LocalStorageService } from '../../../../../app/services/localStorageService'; import { MarkdownComponent } from 'ngx-markdown'; +import { AiSummaryDisplayComponent } from '../ai-summary-display/ai-summary-display.component'; @Component({ imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], selector: 'open-response-summary', - templateUrl: './open-response-summary-display.component.html' + templateUrl: '../ai-summary-display/ai-summary-display.component.html' }) -export class OpenResponseSummaryDisplayComponent extends TeacherSummaryDisplayComponent { - protected awsBedRockService: AwsBedRockService = inject(AwsBedRockService); - protected generatingSummary: boolean = false; - protected hasStudentResponses: boolean = false; - private localStorageService: LocalStorageService = inject(LocalStorageService); - protected newSummaryAvailable: boolean = false; - protected summary: string; - protected summaryDate: Date; - private summaryTimestamp: number; - - ngOnInit(): void { - this.renderDisplay(); +export class OpenResponseSummaryDisplayComponent extends AiSummaryDisplayComponent { + protected getSystemPrompt(prompt: string): string { + return `You are a teacher who is summarizing student responses to the following question: "${prompt}". + Each student response is in the format: Response. + In the same language as the question, provide a summary of the responses in 100 words or less.`; } - protected renderDisplay(): void { - super.renderDisplay(); - const latestPeriodComponentStates = this.getLatestPeriodComponentStates(); - this.hasStudentResponses = latestPeriodComponentStates.length > 0; - if (!this.hasStudentResponses) { - return; - } - this.summary = - this.localStorageService.getItem( - `openResponseSummary-${this.periodId}-${this.nodeId}-${this.componentId}` - ) || ''; - this.summaryTimestamp = - this.localStorageService.getItem( - `openResponseSummary-timestamp-${this.periodId}-${this.nodeId}-${this.componentId}` - ) || 0; - this.summaryDate = new Date(this.summaryTimestamp); - const lastResponseTime = latestPeriodComponentStates.reduce((max, state) => { - return Math.max(max, state.serverSaveTime); - }, 0); - this.newSummaryAvailable = - this.summaryTimestamp > 0 && lastResponseTime > this.summaryTimestamp; + protected getStudentResponses(): string { + return this.getLatestPeriodComponentStates().reduce( + (soFar, state) => `${soFar}${state.studentData.response}`, + '' + ); } - protected getLatestPeriodComponentStates(): any[] { return (this.dataService as TeacherDataService) .getComponentStatesByComponentId(this.componentId) @@ -65,34 +38,4 @@ export class OpenResponseSummaryDisplayComponent extends TeacherSummaryDisplayCo [] ); } - - protected async generateSummary(): Promise { - this.generatingSummary = true; - const prompt = this.projectService.getComponent(this.nodeId, this.componentId).prompt; - const systemPrompt = `You are a teacher who is summarizing student responses to the following question: "${prompt}". - Each student response is in the format: Response. - In the same language as the question, provide a summary of the responses in 100 words or less.`; - const messages = [ - new ChatMessage('system', systemPrompt, this.nodeId), - new ChatMessage('user', this.getStudentResponses(), this.nodeId) - ]; - this.summary = await this.awsBedRockService.sendMessage(messages); - this.localStorageService.setItem( - `openResponseSummary-${this.periodId}-${this.nodeId}-${this.componentId}`, - this.summary - ); - this.localStorageService.setItem( - `openResponseSummary-timestamp-${this.periodId}-${this.nodeId}-${this.componentId}`, - new Date().getTime() - ); - this.generatingSummary = false; - this.newSummaryAvailable = false; - } - - private getStudentResponses(): string { - return this.getLatestPeriodComponentStates().reduce( - (soFar, state) => `${soFar}${state.studentData.response}`, - '' - ); - } } diff --git a/src/messages.xlf b/src/messages.xlf index 89c84a09e92..43f86d324f9 100644 --- a/src/messages.xlf +++ b/src/messages.xlf @@ -21857,7 +21857,7 @@ If this problem continues, let your teacher know and move on to the next activit 5,7
- src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html 5,7
@@ -21872,7 +21872,7 @@ If this problem continues, let your teacher know and move on to the next activit 12,16 - src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html 12,16 @@ -21887,7 +21887,7 @@ If this problem continues, let your teacher know and move on to the next activit 18,22 - src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html 18,22 @@ -21902,7 +21902,7 @@ If this problem continues, let your teacher know and move on to the next activit 23,25 - src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html 23,25 From 2c0276b85180284aeeec51047de6ca85067ab395 Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Wed, 4 Feb 2026 12:17:41 -0800 Subject: [PATCH 04/17] refactor: make code more concise --- .../ai-summary-display.component.html | 2 +- .../ai-summary-display.component.ts | 22 +++++++++++-------- .../discussion-summary-display.component.ts | 5 ++--- ...response-summary-display.component.spec.ts | 18 +++++++-------- ...open-response-summary-display.component.ts | 4 ++-- src/messages.xlf | 2 +- 6 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html b/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html index f4bda35f709..f7ecfda29cc 100644 --- a/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html +++ b/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html @@ -16,7 +16,7 @@
Summary generated {{ summaryDate | date: 'short' }} from - {{ getLatestPeriodComponentStates().length }} responses + {{ latestComponentStates.length }} responses
} } @else { diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts b/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts index f737e49d68f..d01fd01026a 100644 --- a/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts @@ -18,6 +18,7 @@ export abstract class AiSummaryDisplayComponent extends TeacherSummaryDisplayCom protected awsBedRockService: AwsBedRockService = inject(AwsBedRockService); protected generatingSummary: boolean = false; protected hasStudentResponses: boolean = false; + protected latestComponentStates: any[] = []; private localStorageService: LocalStorageService = inject(LocalStorageService); protected newSummaryAvailable: boolean = false; protected summary: string; @@ -29,22 +30,25 @@ export abstract class AiSummaryDisplayComponent extends TeacherSummaryDisplayCom protected renderDisplay(): void { super.renderDisplay(); - const latestPeriodComponentStates = this.getLatestPeriodComponentStates(); - this.hasStudentResponses = latestPeriodComponentStates.length > 0; + this.latestComponentStates = this.getLatestComponentStates(); + this.hasStudentResponses = this.latestComponentStates.length > 0; if (!this.hasStudentResponses) { return; } this.summary = this.localStorageService.getItem(this.getSummaryKey()) || ''; - const summaryTime = this.localStorageService.getItem(this.getSummaryTimestampKey()) || 0; + const summaryTime = this.localStorageService.getItem(this.getSummaryTimeKey()) || 0; this.summaryDate = new Date(summaryTime); - const lastResponseTime = latestPeriodComponentStates.reduce( + this.newSummaryAvailable = summaryTime > 0 && this.getLastResponseTime() > summaryTime; + } + + private getLastResponseTime(): number { + return this.latestComponentStates.reduce( (max, state) => Math.max(max, state.serverSaveTime), 0 ); - this.newSummaryAvailable = summaryTime > 0 && lastResponseTime > summaryTime; } - protected getLatestPeriodComponentStates(): any[] { + protected getLatestComponentStates(): any[] { return (this.dataService as TeacherDataService) .getComponentStatesByComponentId(this.componentId) .filter((state) => state.periodId === this.periodId || this.periodId === -1) @@ -60,7 +64,7 @@ export abstract class AiSummaryDisplayComponent extends TeacherSummaryDisplayCom ]); this.localStorageService.setItem(this.getSummaryKey(), this.summary); const summaryTime = new Date().getTime(); - this.localStorageService.setItem(this.getSummaryTimestampKey(), summaryTime); + this.localStorageService.setItem(this.getSummaryTimeKey(), summaryTime); this.summaryDate = new Date(summaryTime); this.generatingSummary = false; this.newSummaryAvailable = false; @@ -74,7 +78,7 @@ export abstract class AiSummaryDisplayComponent extends TeacherSummaryDisplayCom return `component-summary-${this.periodId}-${this.nodeId}-${this.componentId}`; } - private getSummaryTimestampKey(): string { - return `component-summary-timestamp-${this.periodId}-${this.nodeId}-${this.componentId}`; + private getSummaryTimeKey(): string { + return `component-summary-time-${this.periodId}-${this.nodeId}-${this.componentId}`; } } diff --git a/src/assets/wise5/directives/teacher-summary-display/discussion-summary-display/discussion-summary-display.component.ts b/src/assets/wise5/directives/teacher-summary-display/discussion-summary-display/discussion-summary-display.component.ts index b701a0c131a..9f26e91fe93 100644 --- a/src/assets/wise5/directives/teacher-summary-display/discussion-summary-display/discussion-summary-display.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/discussion-summary-display/discussion-summary-display.component.ts @@ -33,11 +33,10 @@ export class DiscussionSummaryDisplayComponent extends AiSummaryDisplayComponent } private getDiscussionThreads(): Thread[] { - const states = this.getLatestPeriodComponentStates(); - const threads = states + const threads = this.latestComponentStates .filter((state) => state.studentData.componentStateIdReplyingTo == null) .map((post) => ({ id: post.id, post: post.studentData.response, replies: [] })); - states + this.latestComponentStates .filter((state) => state.studentData.componentStateIdReplyingTo != null) .forEach((reply) => { threads diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts index 27b5797ea79..11fb770baeb 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts @@ -112,12 +112,12 @@ describe('OpenResponseSummaryDisplayComponent', () => { }); }); - describe('getLatestPeriodComponentStates', () => { + describe('getLatestComponentStates', () => { it('should filter component states by period ID', () => { const componentStates = getComponentStates(); spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); component.periodId = 1; - const result = component['getLatestPeriodComponentStates'](); + const result = component['getLatestComponentStates'](); expect(result.every((state) => state.periodId === 1)).toBe(true); }); @@ -137,7 +137,7 @@ describe('OpenResponseSummaryDisplayComponent', () => { ]; spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); component.periodId = -1; - const result = component['getLatestPeriodComponentStates'](); + const result = component['getLatestComponentStates'](); expect(result.length).toBe(4); }); @@ -157,7 +157,7 @@ describe('OpenResponseSummaryDisplayComponent', () => { ]; spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); component.periodId = 1; - const result = component['getLatestPeriodComponentStates'](); + const result = component['getLatestComponentStates'](); const workgroup1States = result.filter((state) => state.workgroupId === 1); expect(workgroup1States.length).toBe(1); expect(workgroup1States[0].serverSaveTime).toBe(5000); @@ -209,9 +209,7 @@ describe('OpenResponseSummaryDisplayComponent', () => { const beforeTime = new Date().getTime(); await component['generateSummary'](); const afterTime = new Date().getTime(); - const timestampCall = setItemSpy.calls - .all() - .find((call) => call.args[0].includes('timestamp')); + const timestampCall = setItemSpy.calls.all().find((call) => call.args[0].includes('time')); expect(timestampCall).toBeDefined(); expect(timestampCall.args[1]).toBeGreaterThanOrEqual(beforeTime); expect(timestampCall.args[1]).toBeLessThanOrEqual(afterTime); @@ -290,7 +288,7 @@ describe('OpenResponseSummaryDisplayComponent', () => { spyOn(localStorageService, 'getItem') .withArgs('component-summary-1-node1-component1') .and.returnValue('Old summary') - .withArgs('component-summary-timestamp-1-node1-component1') + .withArgs('component-summary-time-1-node1-component1') .and.returnValue(oldTimestamp); component.ngOnInit(); fixture.detectChanges(); @@ -312,7 +310,7 @@ describe('OpenResponseSummaryDisplayComponent', () => { spyOn(localStorageService, 'getItem') .withArgs('component-summary-1-node1-component1') .and.returnValue(savedSummary) - .withArgs('component-summary-timestamp-1-node1-component1') + .withArgs('component-summary-time-1-node1-component1') .and.returnValue(Date.now() + 100000); component.ngOnInit(); fixture.detectChanges(); @@ -326,7 +324,7 @@ describe('OpenResponseSummaryDisplayComponent', () => { spyOn(localStorageService, 'getItem') .withArgs('component-summary-1-node1-component1') .and.returnValue(savedSummary) - .withArgs('component-summary-timestamp-1-node1-component1') + .withArgs('component-summary-time-1-node1-component1') .and.returnValue(Date.now() + 100000); component.ngOnInit(); fixture.detectChanges(); diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts index 6d91bb38aca..b1350f0ad6a 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts @@ -20,12 +20,12 @@ export class OpenResponseSummaryDisplayComponent extends AiSummaryDisplayCompone } protected getStudentResponses(): string { - return this.getLatestPeriodComponentStates().reduce( + return this.getLatestComponentStates().reduce( (soFar, state) => `${soFar}${state.studentData.response}`, '' ); } - protected getLatestPeriodComponentStates(): any[] { + protected getLatestComponentStates(): any[] { return (this.dataService as TeacherDataService) .getComponentStatesByComponentId(this.componentId) .filter((state) => state.periodId === this.periodId || this.periodId === -1) diff --git a/src/messages.xlf b/src/messages.xlf index 43f86d324f9..fa3a2e48dd3 100644 --- a/src/messages.xlf +++ b/src/messages.xlf @@ -21877,7 +21877,7 @@ If this problem continues, let your teacher know and move on to the next activit - Summary generated from responses + Summary generated from responses src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html 18,22 From e24050159739c91d57d547c503a39cf1eb089a74 Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Mon, 16 Mar 2026 15:44:25 -0700 Subject: [PATCH 05/17] Rename class to DiscussionAISummaryComponent --- .../component-summary/component-summary.component.html | 2 +- .../component-summary/component-summary.component.ts | 4 ++-- .../discussion-ai-summary.component.ts} | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) rename src/assets/wise5/directives/teacher-summary-display/{discussion-summary-display/discussion-summary-display.component.ts => discussion-ai-summary/discussion-ai-summary.component.ts} (91%) diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html index e72c421fa90..14d19b2b091 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html @@ -111,7 +111,7 @@ [ngTemplateOutlet]="expandSummaryTpl" [ngTemplateOutletContext]="{ type: 'discussion' }" /> - PostReply 1Reply 2. From efe24eb394c99b51add434487527c7388b7ca6ad Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Mon, 16 Mar 2026 15:49:40 -0700 Subject: [PATCH 06/17] Rename class to OpenResponseAiSummaryComponent --- .../component-summary.component.html | 2 +- .../component-summary/component-summary.component.ts | 4 ++-- .../open-response-ai-summary.component.spec.ts} | 12 ++++++------ .../open-response-ai-summary.component.ts} | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) rename src/assets/wise5/directives/teacher-summary-display/{open-response-summary-display/open-response-summary-display.component.spec.ts => open-response-ai-summary/open-response-ai-summary.component.spec.ts} (97%) rename src/assets/wise5/directives/teacher-summary-display/{open-response-summary-display/open-response-summary-display.component.ts => open-response-ai-summary/open-response-ai-summary.component.ts} (93%) diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html index 14d19b2b091..3e9a9d655bf 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html @@ -96,7 +96,7 @@ } @else if (component?.type === 'OpenResponse' && hasStudentWork) { - { - let component: OpenResponseSummaryDisplayComponent; - let fixture: ComponentFixture; +describe('OpenResponseAiSummaryComponent', () => { + let component: OpenResponseAiSummaryComponent; + let fixture: ComponentFixture; let awsBedRockService: AwsBedRockService; let localStorageService: LocalStorageService; let dataService: TeacherDataService; @@ -24,7 +24,7 @@ describe('OpenResponseSummaryDisplayComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [OpenResponseSummaryDisplayComponent, MockComponent(MarkdownComponent)], + imports: [OpenResponseAiSummaryComponent, MockComponent(MarkdownComponent)], providers: [ provideHttpClient(), provideHttpClientTesting(), @@ -54,7 +54,7 @@ describe('OpenResponseSummaryDisplayComponent', () => { prompt: 'What is your opinion on climate change?' } as any); - fixture = TestBed.createComponent(OpenResponseSummaryDisplayComponent); + fixture = TestBed.createComponent(OpenResponseAiSummaryComponent); component = fixture.componentInstance; component.nodeId = 'node1'; component.componentId = 'component1'; diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts similarity index 93% rename from src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts rename to src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts index b1350f0ad6a..a5de53c31a0 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts @@ -9,10 +9,10 @@ import { AiSummaryDisplayComponent } from '../ai-summary-display/ai-summary-disp @Component({ imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], - selector: 'open-response-summary', + selector: 'open-response-ai-summary', templateUrl: '../ai-summary-display/ai-summary-display.component.html' }) -export class OpenResponseSummaryDisplayComponent extends AiSummaryDisplayComponent { +export class OpenResponseAiSummaryComponent extends AiSummaryDisplayComponent { protected getSystemPrompt(prompt: string): string { return `You are a teacher who is summarizing student responses to the following question: "${prompt}". Each student response is in the format: Response. From ebd1f27c54fa047969dff084ac8271ca0321fb3f Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Tue, 17 Mar 2026 16:58:11 -0700 Subject: [PATCH 07/17] AiSummaryDisplayComponent is now its own base class, no longer extends TeacherSummaryDisplayComponent. They didn't have much overlap to begin with. --- .../component-summary.component.html | 4 -- .../ai-summary-display.component.ts | 37 +++++++++++-------- ...open-response-ai-summary.component.spec.ts | 15 ++------ .../open-response-ai-summary.component.ts | 6 ++- 4 files changed, 29 insertions(+), 33 deletions(-) diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html index 3e9a9d655bf..3733027bc3b 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html @@ -100,8 +100,6 @@ [nodeId]="node.id" [componentId]="component.id" [periodId]="periodId" - [source]="source" - [doRender]="true" /> @@ -115,8 +113,6 @@ [nodeId]="node.id" [componentId]="component.id" [periodId]="periodId" - [source]="source" - [doRender]="true" /> 0; if (!this.hasStudentResponses) { @@ -41,6 +46,13 @@ export abstract class AiSummaryDisplayComponent extends TeacherSummaryDisplayCom this.newSummaryAvailable = summaryTime > 0 && this.getLastResponseTime() > summaryTime; } + protected getLatestComponentStates(): any[] { + return this.dataService + .getComponentStatesByComponentId(this.componentId) + .filter((state) => state.periodId === this.periodId || this.periodId === -1) + .sort((a, b) => a.serverSaveTime - b.serverSaveTime); + } + private getLastResponseTime(): number { return this.latestComponentStates.reduce( (max, state) => Math.max(max, state.serverSaveTime), @@ -48,13 +60,6 @@ export abstract class AiSummaryDisplayComponent extends TeacherSummaryDisplayCom ); } - protected getLatestComponentStates(): any[] { - return (this.dataService as TeacherDataService) - .getComponentStatesByComponentId(this.componentId) - .filter((state) => state.periodId === this.periodId || this.periodId === -1) - .sort((a, b) => a.serverSaveTime - b.serverSaveTime); - } - protected async generateSummary(): Promise { this.generatingSummary = true; const prompt = this.projectService.getComponent(this.nodeId, this.componentId).prompt; diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts index cd50f9399b1..71940d408eb 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts @@ -13,6 +13,7 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { DataService } from '../../../../../app/services/data.service'; import { MarkdownComponent, MarkdownService } from 'ngx-markdown'; +import { TeacherProjectService } from '../../../services/teacherProjectService'; describe('OpenResponseAiSummaryComponent', () => { let component: OpenResponseAiSummaryComponent; @@ -36,7 +37,7 @@ describe('OpenResponseAiSummaryComponent', () => { CRaterService, LocalStorageService, MarkdownService, - ProjectService, + TeacherProjectService, SummaryService, TeacherDataService ) @@ -45,8 +46,8 @@ describe('OpenResponseAiSummaryComponent', () => { awsBedRockService = TestBed.inject(AwsBedRockService); localStorageService = TestBed.inject(LocalStorageService); - dataService = TestBed.inject(TeacherDataService) as TeacherDataService; - projectService = TestBed.inject(ProjectService); + dataService = TestBed.inject(TeacherDataService); + projectService = TestBed.inject(TeacherProjectService); spyOn(projectService, 'getComponent').and.returnValue({ id: 'component1', @@ -62,14 +63,6 @@ describe('OpenResponseAiSummaryComponent', () => { }); describe('ngOnInit', () => { - it('should call renderDisplay', () => { - spyOn(component as any, 'renderDisplay'); - component.ngOnInit(); - expect((component as any).renderDisplay).toHaveBeenCalled(); - }); - }); - - describe('renderDisplay', () => { it('should set hasStudentResponses to false when no component states exist', () => { spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue([]); component.ngOnInit(); diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts index a5de53c31a0..8fa6ac636ef 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts @@ -2,11 +2,13 @@ import { DatePipe } from '@angular/common'; import { Component } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; -import { TeacherDataService } from '../../../services/teacherDataService'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MarkdownComponent } from 'ngx-markdown'; import { AiSummaryDisplayComponent } from '../ai-summary-display/ai-summary-display.component'; +/** + * Uses an LLM to summarize students' responses to open response questions. + */ @Component({ imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], selector: 'open-response-ai-summary', @@ -26,7 +28,7 @@ export class OpenResponseAiSummaryComponent extends AiSummaryDisplayComponent { ); } protected getLatestComponentStates(): any[] { - return (this.dataService as TeacherDataService) + return this.dataService .getComponentStatesByComponentId(this.componentId) .filter((state) => state.periodId === this.periodId || this.periodId === -1) .sort((a, b) => a.serverSaveTime - b.serverSaveTime) From 28317176b4977a97da351887d40c31eb8ce26674 Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Tue, 17 Mar 2026 17:06:54 -0700 Subject: [PATCH 08/17] Rename AiSummaryDisplayComponent -> AiSummaryComponent --- .../ai-summary.component.html} | 0 .../ai-summary.component.ts} | 4 ++-- .../discussion-ai-summary.component.ts | 6 ++--- .../open-response-ai-summary.component.ts | 6 ++--- src/messages.xlf | 24 +++++++++---------- 5 files changed, 20 insertions(+), 20 deletions(-) rename src/assets/wise5/directives/teacher-summary-display/{ai-summary-display/ai-summary-display.component.html => ai-summary/ai-summary.component.html} (100%) rename src/assets/wise5/directives/teacher-summary-display/{ai-summary-display/ai-summary-display.component.ts => ai-summary/ai-summary.component.ts} (97%) diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html similarity index 100% rename from src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html rename to src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts similarity index 97% rename from src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts rename to src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts index 488744cfceb..65ad0fdcc3e 100644 --- a/src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts @@ -15,9 +15,9 @@ import { TeacherProjectService } from '../../../services/teacherProjectService'; */ @Component({ imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], - templateUrl: './ai-summary-display.component.html' + templateUrl: './ai-summary.component.html' }) -export abstract class AiSummaryDisplayComponent { +export abstract class AiSummaryComponent { @Input() componentId: string; @Input() nodeId: string; @Input() periodId: number; diff --git a/src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts b/src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts index 57ca0cb20f6..c1fb900838d 100644 --- a/src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts @@ -3,7 +3,7 @@ import { MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MarkdownComponent } from 'ngx-markdown'; -import { AiSummaryDisplayComponent } from '../ai-summary-display/ai-summary-display.component'; +import { AiSummaryComponent } from '../ai-summary/ai-summary.component'; import { DatePipe } from '@angular/common'; interface Thread { @@ -18,9 +18,9 @@ interface Thread { @Component({ imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], selector: 'discussion-ai-summary', - templateUrl: '../ai-summary-display/ai-summary-display.component.html' + templateUrl: '../ai-summary/ai-summary.component.html' }) -export class DiscussionAiSummaryComponent extends AiSummaryDisplayComponent { +export class DiscussionAiSummaryComponent extends AiSummaryComponent { protected getSystemPrompt(prompt: string): string { return `You are a teacher who is summarizing students' discussion threads, which include posts and replies to the following question: "${prompt}". Each thread is in the format: PostReply 1Reply 2. diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts index 8fa6ac636ef..42a914263a4 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts @@ -4,7 +4,7 @@ import { MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MarkdownComponent } from 'ngx-markdown'; -import { AiSummaryDisplayComponent } from '../ai-summary-display/ai-summary-display.component'; +import { AiSummaryComponent } from '../ai-summary/ai-summary.component'; /** * Uses an LLM to summarize students' responses to open response questions. @@ -12,9 +12,9 @@ import { AiSummaryDisplayComponent } from '../ai-summary-display/ai-summary-disp @Component({ imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], selector: 'open-response-ai-summary', - templateUrl: '../ai-summary-display/ai-summary-display.component.html' + templateUrl: '../ai-summary/ai-summary.component.html' }) -export class OpenResponseAiSummaryComponent extends AiSummaryDisplayComponent { +export class OpenResponseAiSummaryComponent extends AiSummaryComponent { protected getSystemPrompt(prompt: string): string { return `You are a teacher who is summarizing student responses to the following question: "${prompt}". Each student response is in the format: Response. diff --git a/src/messages.xlf b/src/messages.xlf index 49da4d92e16..a63098a0882 100644 --- a/src/messages.xlf +++ b/src/messages.xlf @@ -22557,60 +22557,60 @@ If this problem continues, let your teacher know and move on to the next activit Generate Class Summary - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 5,7 - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 5,7 - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 5,7 *New responses since last summary - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 12,16 - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 12,16 - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 12,16 Summary generated from responses - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 18,22 - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 18,22 - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 18,22 No student responses - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 23,25 - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 23,25 - src/assets/wise5/directives/teacher-summary-display/ai-summary-display/ai-summary-display.component.html + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html 23,25 From c25dc724923014477e86e59423d46f4332630986 Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Wed, 18 Mar 2026 13:50:19 -0700 Subject: [PATCH 09/17] Rename to AwsBedRockChatService. Rename class variable to chatService. Move service classes to app/services/chat --- src/app/chatbot/chatbot.component.spec.ts | 10 ++++++---- src/app/chatbot/chatbot.component.ts | 4 ++-- .../chat/awsBedRockChat.service.ts} | 2 +- .../{chatbot => services/chat}/chat.service.ts | 2 +- .../ai-summary/ai-summary.component.ts | 18 +++++++++--------- .../open-response-ai-summary.component.spec.ts | 8 ++++---- 6 files changed, 23 insertions(+), 21 deletions(-) rename src/app/{chatbot/awsBedRock.service.ts => services/chat/awsBedRockChat.service.ts} (86%) rename src/app/{chatbot => services/chat}/chat.service.ts (97%) diff --git a/src/app/chatbot/chatbot.component.spec.ts b/src/app/chatbot/chatbot.component.spec.ts index 22aa7a0f24a..f12c3ea52b5 100644 --- a/src/app/chatbot/chatbot.component.spec.ts +++ b/src/app/chatbot/chatbot.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ChatbotComponent } from './chatbot.component'; import { ChatbotService } from './chatbot.service'; -import { AwsBedRockService } from './awsBedRock.service'; +import { AwsBedRockChatService } from '../services/chat/awsBedRockChat.service'; import { ConfigService } from '../../assets/wise5/services/configService'; import { DataService } from '../services/data.service'; import { ProjectService } from '../../assets/wise5/services/projectService'; @@ -14,7 +14,7 @@ describe('ChatbotComponent', () => { let component: ChatbotComponent; let fixture: ComponentFixture; let chatbotService: jasmine.SpyObj; - let awsBedRockService: jasmine.SpyObj; + let awsBedRockService: jasmine.SpyObj; let configService: jasmine.SpyObj; let dataService: jasmine.SpyObj; let projectService: jasmine.SpyObj; @@ -66,7 +66,7 @@ describe('ChatbotComponent', () => { imports: [ChatbotComponent], providers: [ { provide: ChatbotService, useValue: chatbotServiceSpy }, - { provide: AwsBedRockService, useValue: awsBedRockServiceSpy }, + { provide: AwsBedRockChatService, useValue: awsBedRockServiceSpy }, { provide: ConfigService, useValue: configServiceSpy }, { provide: DataService, useValue: dataServiceSpy }, { provide: ProjectService, useValue: projectServiceSpy }, @@ -76,7 +76,9 @@ describe('ChatbotComponent', () => { }).compileComponents(); chatbotService = TestBed.inject(ChatbotService) as jasmine.SpyObj; - awsBedRockService = TestBed.inject(AwsBedRockService) as jasmine.SpyObj; + awsBedRockService = TestBed.inject( + AwsBedRockChatService + ) as jasmine.SpyObj; configService = TestBed.inject(ConfigService) as jasmine.SpyObj; dataService = TestBed.inject(DataService) as jasmine.SpyObj; projectService = TestBed.inject(ProjectService) as jasmine.SpyObj; diff --git a/src/app/chatbot/chatbot.component.ts b/src/app/chatbot/chatbot.component.ts index 47429e881fd..a36b8c9422e 100644 --- a/src/app/chatbot/chatbot.component.ts +++ b/src/app/chatbot/chatbot.component.ts @@ -15,7 +15,7 @@ import { ChatbotService } from './chatbot.service'; import { ConfigService } from '../../assets/wise5/services/configService'; import { DataService } from '../services/data.service'; import { Chat, ChatMessage } from './chat'; -import { AwsBedRockService } from './awsBedRock.service'; +import { AwsBedRockChatService } from '../services/chat/awsBedRockChat.service'; import { ProjectService } from '../../assets/wise5/services/projectService'; import { MarkdownComponent } from 'ngx-markdown'; import { ChatHistoryDialogComponent } from './chat-history-dialog.component'; @@ -42,7 +42,7 @@ import { MatDividerModule } from '@angular/material/divider'; export class ChatbotComponent { private breakpointObserver = inject(BreakpointObserver); private chatbotService: ChatbotService = inject(ChatbotService); - private awsBedRockService: AwsBedRockService = inject(AwsBedRockService); + private awsBedRockService: AwsBedRockChatService = inject(AwsBedRockChatService); private configService: ConfigService = inject(ConfigService); private dataService: DataService = inject(DataService); private projectService = inject(ProjectService); diff --git a/src/app/chatbot/awsBedRock.service.ts b/src/app/services/chat/awsBedRockChat.service.ts similarity index 86% rename from src/app/chatbot/awsBedRock.service.ts rename to src/app/services/chat/awsBedRockChat.service.ts index efa3fec6d78..5ebbb1314f3 100644 --- a/src/app/chatbot/awsBedRock.service.ts +++ b/src/app/services/chat/awsBedRockChat.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { ChatService } from './chat.service'; @Injectable({ providedIn: 'root' }) -export class AwsBedRockService extends ChatService { +export class AwsBedRockChatService extends ChatService { protected chatEndpoint = '/api/aws-bedrock/chat'; protected model: string = 'google.gemma-3-27b-it'; diff --git a/src/app/chatbot/chat.service.ts b/src/app/services/chat/chat.service.ts similarity index 97% rename from src/app/chatbot/chat.service.ts rename to src/app/services/chat/chat.service.ts index 00cac5a0f8a..985717eb60e 100644 --- a/src/app/chatbot/chat.service.ts +++ b/src/app/services/chat/chat.service.ts @@ -1,5 +1,5 @@ import { inject, Injectable } from '@angular/core'; -import { ChatMessage } from './chat'; +import { ChatMessage } from '../../chatbot/chat'; import { firstValueFrom } from 'rxjs'; import { HttpClient } from '@angular/common/http'; diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts index 65ad0fdcc3e..fc3fdd2adbc 100644 --- a/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts @@ -1,7 +1,7 @@ import { Component, inject, Input } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; -import { AwsBedRockService } from '../../../../../app/chatbot/awsBedRock.service'; +import { AwsBedRockChatService } from '../../../../../app/services/chat/awsBedRockChat.service'; import { ChatMessage } from '../../../../../app/chatbot/chat'; import { TeacherDataService } from '../../../services/teacherDataService'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; @@ -9,6 +9,7 @@ import { LocalStorageService } from '../../../../../app/services/localStorageSer import { MarkdownComponent } from 'ngx-markdown'; import { DatePipe } from '@angular/common'; import { TeacherProjectService } from '../../../services/teacherProjectService'; +import { ChatService } from '../../../../../app/services/chat/chat.service'; /** * Abstract base class for components that use an LLM to summarize student responses. @@ -22,7 +23,7 @@ export abstract class AiSummaryComponent { @Input() nodeId: string; @Input() periodId: number; - protected awsBedRockService: AwsBedRockService = inject(AwsBedRockService); + private chatService: ChatService = inject(AwsBedRockChatService); protected dataService: TeacherDataService = inject(TeacherDataService); private localStorageService: LocalStorageService = inject(LocalStorageService); protected projectService: TeacherProjectService = inject(TeacherProjectService); @@ -37,13 +38,12 @@ export abstract class AiSummaryComponent { ngOnInit(): void { this.latestComponentStates = this.getLatestComponentStates(); this.hasStudentResponses = this.latestComponentStates.length > 0; - if (!this.hasStudentResponses) { - return; + if (this.hasStudentResponses) { + this.summary = this.localStorageService.getItem(this.getSummaryKey()) || ''; + const summaryTime = this.localStorageService.getItem(this.getSummaryTimeKey()) || 0; + this.summaryDate = new Date(summaryTime); + this.newSummaryAvailable = summaryTime > 0 && this.getLastResponseTime() > summaryTime; } - this.summary = this.localStorageService.getItem(this.getSummaryKey()) || ''; - const summaryTime = this.localStorageService.getItem(this.getSummaryTimeKey()) || 0; - this.summaryDate = new Date(summaryTime); - this.newSummaryAvailable = summaryTime > 0 && this.getLastResponseTime() > summaryTime; } protected getLatestComponentStates(): any[] { @@ -63,7 +63,7 @@ export abstract class AiSummaryComponent { protected async generateSummary(): Promise { this.generatingSummary = true; const prompt = this.projectService.getComponent(this.nodeId, this.componentId).prompt; - this.summary = await this.awsBedRockService.sendMessage([ + this.summary = await this.chatService.sendMessage([ new ChatMessage('system', this.getSystemPrompt(prompt), this.nodeId), new ChatMessage('user', this.getStudentResponses(), this.nodeId) ]); diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts index 71940d408eb..e9348306a43 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts @@ -6,7 +6,7 @@ import { ConfigService } from '../../../services/configService'; import { CRaterService } from '../../../services/cRaterService'; import { ProjectService } from '../../../services/projectService'; import { SummaryService } from '../../../components/summary/summaryService'; -import { AwsBedRockService } from '../../../../../app/chatbot/awsBedRock.service'; +import { AwsBedRockChatService } from '../../../../../app/services/chat/awsBedRockChat.service'; import { TeacherDataService } from '../../../services/teacherDataService'; import { LocalStorageService } from '../../../../../app/services/localStorageService'; import { provideHttpClient } from '@angular/common/http'; @@ -18,7 +18,7 @@ import { TeacherProjectService } from '../../../services/teacherProjectService'; describe('OpenResponseAiSummaryComponent', () => { let component: OpenResponseAiSummaryComponent; let fixture: ComponentFixture; - let awsBedRockService: AwsBedRockService; + let awsBedRockService: AwsBedRockChatService; let localStorageService: LocalStorageService; let dataService: TeacherDataService; let projectService: ProjectService; @@ -32,7 +32,7 @@ describe('OpenResponseAiSummaryComponent', () => { { provide: DataService, useExisting: TeacherDataService }, MockProviders( AnnotationService, - AwsBedRockService, + AwsBedRockChatService, ConfigService, CRaterService, LocalStorageService, @@ -44,7 +44,7 @@ describe('OpenResponseAiSummaryComponent', () => { ] }).compileComponents(); - awsBedRockService = TestBed.inject(AwsBedRockService); + awsBedRockService = TestBed.inject(AwsBedRockChatService); localStorageService = TestBed.inject(LocalStorageService); dataService = TestBed.inject(TeacherDataService); projectService = TestBed.inject(TeacherProjectService); From c2f085a1af03a32f24479c7ac482aff9333b2aeb Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Wed, 18 Mar 2026 16:18:22 -0700 Subject: [PATCH 10/17] Move generateChatTitle() to ChatbotComponent --- src/app/chatbot/chatbot.component.spec.ts | 8 +++---- src/app/chatbot/chatbot.component.ts | 28 +++++++++++++++++++---- src/app/services/chat/chat.service.ts | 18 --------------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/app/chatbot/chatbot.component.spec.ts b/src/app/chatbot/chatbot.component.spec.ts index f12c3ea52b5..3e64c532bdb 100644 --- a/src/app/chatbot/chatbot.component.spec.ts +++ b/src/app/chatbot/chatbot.component.spec.ts @@ -137,7 +137,7 @@ describe('ChatbotComponent', () => { component['userInput'] = userMessage; awsBedRockService.sendMessage.and.returnValue(Promise.resolve(assistantResponse)); - awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve('New Title')); + spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve('New Title')); await component['sendMessage'](); expect(component['messages'].length).toBe(2); @@ -159,11 +159,11 @@ describe('ChatbotComponent', () => { // First user message (only system message exists initially) component['messages'] = []; awsBedRockService.sendMessage.and.returnValue(Promise.resolve(assistantResponse)); - awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve(newTitle)); + spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve(newTitle)); await component['sendMessage'](); - expect(awsBedRockService.generateChatTitle).toHaveBeenCalledWith(userMessage); + expect(component['generateChatTitle']).toHaveBeenCalledWith(userMessage); expect(component['currentChat']?.title).toBe(newTitle); expect(chatbotService.updateChat).toHaveBeenCalled(); }); @@ -339,7 +339,7 @@ describe('ChatbotComponent', () => { it('should send message on Enter key press', () => { component['userInput'] = 'Hello'; awsBedRockService.sendMessage.and.returnValue(Promise.resolve('Response')); - awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve('New Title')); + spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve('New Title')); const event = new KeyboardEvent('keypress', { key: 'Enter' }); spyOn(event, 'preventDefault'); diff --git a/src/app/chatbot/chatbot.component.ts b/src/app/chatbot/chatbot.component.ts index a36b8c9422e..7e3c6767923 100644 --- a/src/app/chatbot/chatbot.component.ts +++ b/src/app/chatbot/chatbot.component.ts @@ -10,7 +10,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { BreakpointObserver } from '@angular/cdk/layout'; -import { skip, Subscription } from 'rxjs'; +import { Subscription } from 'rxjs'; import { ChatbotService } from './chatbot.service'; import { ConfigService } from '../../assets/wise5/services/configService'; import { DataService } from '../services/data.service'; @@ -19,7 +19,7 @@ import { AwsBedRockChatService } from '../services/chat/awsBedRockChat.service'; import { ProjectService } from '../../assets/wise5/services/projectService'; import { MarkdownComponent } from 'ngx-markdown'; import { ChatHistoryDialogComponent } from './chat-history-dialog.component'; -import { MatDividerModule } from '@angular/material/divider'; +import { ChatService } from '../services/chat/chat.service'; @Component({ imports: [ @@ -42,7 +42,7 @@ import { MatDividerModule } from '@angular/material/divider'; export class ChatbotComponent { private breakpointObserver = inject(BreakpointObserver); private chatbotService: ChatbotService = inject(ChatbotService); - private awsBedRockService: AwsBedRockChatService = inject(AwsBedRockChatService); + private chatService: ChatService = inject(AwsBedRockChatService); private configService: ConfigService = inject(ConfigService); private dataService: DataService = inject(DataService); private projectService = inject(ProjectService); @@ -112,7 +112,7 @@ export class ChatbotComponent { this.loading = true; this.scrollToBottom(); try { - const response = await this.awsBedRockService.sendMessage(this.messages); + const response = await this.chatService.sendMessage(this.messages); this.messages.push( new ChatMessage('assistant', response, this.dataService.getCurrentNode().id) ); @@ -154,7 +154,7 @@ export class ChatbotComponent { */ private async generateAndSetChatTitle(firstUserMessage: ChatMessage): Promise { try { - let newTitle = await this.awsBedRockService.generateChatTitle(firstUserMessage.content); + let newTitle = await this.generateChatTitle(firstUserMessage.content); // Remove surrounding quotes if any newTitle = newTitle.replace(/^["'](.*)["']$/, '$1').trim(); if (newTitle) { @@ -165,6 +165,24 @@ export class ChatbotComponent { } } + /** + * Generates a short, concise title for a chat based on the first message. + * @param message The first user message content. + * @returns A promise that resolves to the generated title. + */ + async generateChatTitle(message: string): Promise { + 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.`; + const messages: ChatMessage[] = [ + new ChatMessage( + 'system', + 'You are a helpful assistant that generates short titles for chat conversations.', + '' + ), + new ChatMessage('user', prompt, '') + ]; + return this.chatService.sendMessage(messages); + } + protected switchToChat(chat: Chat): void { this.currentChat = chat; this.messages = [...chat.messages]; diff --git a/src/app/services/chat/chat.service.ts b/src/app/services/chat/chat.service.ts index 985717eb60e..6384fdf049b 100644 --- a/src/app/services/chat/chat.service.ts +++ b/src/app/services/chat/chat.service.ts @@ -34,22 +34,4 @@ export class ChatService { processResponse(response: string): string { return response; } - - /** - * Generates a short, concise title for a chat based on the first message. - * @param message The first user message content. - * @returns A promise that resolves to the generated title. - */ - async generateChatTitle(message: string): Promise { - 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.`; - const messages: ChatMessage[] = [ - new ChatMessage( - 'system', - 'You are a helpful assistant that generates short titles for chat conversations.', - '' - ), - new ChatMessage('user', prompt, '') - ]; - return this.sendMessage(messages); - } } From e4827e8eb7dcd09516c4b2bae1fc5e92170fa5eb Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Wed, 18 Mar 2026 16:58:18 -0700 Subject: [PATCH 11/17] Make ChatService abstract, and create OpenAiChatService that extends it. ChatbotComponent now uses OpenAiChatService --- src/app/chatbot/chatbot.component.ts | 4 ++-- src/app/services/chat/chat.service.ts | 14 +++++++------- src/app/services/chat/openAiChat.service.ts | 8 ++++++++ 3 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 src/app/services/chat/openAiChat.service.ts diff --git a/src/app/chatbot/chatbot.component.ts b/src/app/chatbot/chatbot.component.ts index 7e3c6767923..65735b53011 100644 --- a/src/app/chatbot/chatbot.component.ts +++ b/src/app/chatbot/chatbot.component.ts @@ -15,11 +15,11 @@ import { ChatbotService } from './chatbot.service'; import { ConfigService } from '../../assets/wise5/services/configService'; import { DataService } from '../services/data.service'; import { Chat, ChatMessage } from './chat'; -import { AwsBedRockChatService } from '../services/chat/awsBedRockChat.service'; import { ProjectService } from '../../assets/wise5/services/projectService'; import { MarkdownComponent } from 'ngx-markdown'; import { ChatHistoryDialogComponent } from './chat-history-dialog.component'; import { ChatService } from '../services/chat/chat.service'; +import { OpenAiChatService } from '../services/chat/openAiChat.service'; @Component({ imports: [ @@ -42,7 +42,7 @@ import { ChatService } from '../services/chat/chat.service'; export class ChatbotComponent { private breakpointObserver = inject(BreakpointObserver); private chatbotService: ChatbotService = inject(ChatbotService); - private chatService: ChatService = inject(AwsBedRockChatService); + private chatService: ChatService = inject(OpenAiChatService); private configService: ConfigService = inject(ConfigService); private dataService: DataService = inject(DataService); private projectService = inject(ProjectService); diff --git a/src/app/services/chat/chat.service.ts b/src/app/services/chat/chat.service.ts index 6384fdf049b..e18b8d1622d 100644 --- a/src/app/services/chat/chat.service.ts +++ b/src/app/services/chat/chat.service.ts @@ -1,18 +1,18 @@ -import { inject, Injectable } from '@angular/core'; +import { inject } from '@angular/core'; import { ChatMessage } from '../../chatbot/chat'; import { firstValueFrom } from 'rxjs'; import { HttpClient } from '@angular/common/http'; -@Injectable({ providedIn: 'root' }) -export class ChatService { - protected chatEndpoint = '/api/chat-gpt'; +export abstract class ChatService { + protected abstract chatEndpoint: string; + protected abstract model: string; + private http = inject(HttpClient); - protected model: string = 'gpt-3.5-turbo'; /** - * Sends a message to the chat-gpt endpoint. + * Sends a message to the chat endpoint. * @param messages The conversation history. - * @returns A promise that resolves to the response from the chat-gpt endpoint. + * @returns A promise that resolves to the response from the chat endpoint. */ async sendMessage(messages: ChatMessage[]): Promise { const payload = { diff --git a/src/app/services/chat/openAiChat.service.ts b/src/app/services/chat/openAiChat.service.ts new file mode 100644 index 00000000000..78b8f6a2d10 --- /dev/null +++ b/src/app/services/chat/openAiChat.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; +import { ChatService } from './chat.service'; + +@Injectable({ providedIn: 'root' }) +export class OpenAiChatService extends ChatService { + protected chatEndpoint = '/api/chat-gpt'; + protected model: string = 'gpt-4o'; +} From 3d22f9ac50f4b881e4fa26c13cd3ce4d847b12ac Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Wed, 18 Mar 2026 17:01:39 -0700 Subject: [PATCH 12/17] Change AiSummary to use OpenAI model as default --- .../ai-summary/ai-summary.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts index fc3fdd2adbc..e6ffea6cdd0 100644 --- a/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts @@ -1,7 +1,6 @@ import { Component, inject, Input } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; -import { AwsBedRockChatService } from '../../../../../app/services/chat/awsBedRockChat.service'; import { ChatMessage } from '../../../../../app/chatbot/chat'; import { TeacherDataService } from '../../../services/teacherDataService'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; @@ -10,6 +9,7 @@ import { MarkdownComponent } from 'ngx-markdown'; import { DatePipe } from '@angular/common'; import { TeacherProjectService } from '../../../services/teacherProjectService'; import { ChatService } from '../../../../../app/services/chat/chat.service'; +import { OpenAiChatService } from '../../../../../app/services/chat/openAiChat.service'; /** * Abstract base class for components that use an LLM to summarize student responses. @@ -23,7 +23,7 @@ export abstract class AiSummaryComponent { @Input() nodeId: string; @Input() periodId: number; - private chatService: ChatService = inject(AwsBedRockChatService); + private chatService: ChatService = inject(OpenAiChatService); protected dataService: TeacherDataService = inject(TeacherDataService); private localStorageService: LocalStorageService = inject(LocalStorageService); protected projectService: TeacherProjectService = inject(TeacherProjectService); From 2c3501fc8f90d5ce0a3dbd453b019d0a437b952b Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Wed, 18 Mar 2026 17:17:24 -0700 Subject: [PATCH 13/17] fix unit tests --- src/app/chatbot/chatbot.component.spec.ts | 28 +++++++++---------- ...open-response-ai-summary.component.spec.ts | 27 +++++++++--------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/app/chatbot/chatbot.component.spec.ts b/src/app/chatbot/chatbot.component.spec.ts index 3e64c532bdb..595cbe7d14f 100644 --- a/src/app/chatbot/chatbot.component.spec.ts +++ b/src/app/chatbot/chatbot.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ChatbotComponent } from './chatbot.component'; import { ChatbotService } from './chatbot.service'; -import { AwsBedRockChatService } from '../services/chat/awsBedRockChat.service'; import { ConfigService } from '../../assets/wise5/services/configService'; import { DataService } from '../services/data.service'; import { ProjectService } from '../../assets/wise5/services/projectService'; @@ -9,12 +8,13 @@ import { BreakpointObserver } from '@angular/cdk/layout'; import { of, throwError } from 'rxjs'; import { Chat, ChatMessage } from './chat'; import { provideHttpClient } from '@angular/common/http'; +import { OpenAiChatService } from '../services/chat/openAiChat.service'; describe('ChatbotComponent', () => { let component: ChatbotComponent; let fixture: ComponentFixture; let chatbotService: jasmine.SpyObj; - let awsBedRockService: jasmine.SpyObj; + let chatService: jasmine.SpyObj; let configService: jasmine.SpyObj; let dataService: jasmine.SpyObj; let projectService: jasmine.SpyObj; @@ -41,7 +41,7 @@ describe('ChatbotComponent', () => { 'updateChat', 'deleteChat' ]); - const awsBedRockServiceSpy = jasmine.createSpyObj('AwsBedRockService', [ + const chatServiceSpy = jasmine.createSpyObj('OpenAiChatService', [ 'sendMessage', 'generateChatTitle' ]); @@ -66,7 +66,7 @@ describe('ChatbotComponent', () => { imports: [ChatbotComponent], providers: [ { provide: ChatbotService, useValue: chatbotServiceSpy }, - { provide: AwsBedRockChatService, useValue: awsBedRockServiceSpy }, + { provide: OpenAiChatService, useValue: chatServiceSpy }, { provide: ConfigService, useValue: configServiceSpy }, { provide: DataService, useValue: dataServiceSpy }, { provide: ProjectService, useValue: projectServiceSpy }, @@ -76,9 +76,7 @@ describe('ChatbotComponent', () => { }).compileComponents(); chatbotService = TestBed.inject(ChatbotService) as jasmine.SpyObj; - awsBedRockService = TestBed.inject( - AwsBedRockChatService - ) as jasmine.SpyObj; + chatService = TestBed.inject(OpenAiChatService) as jasmine.SpyObj; configService = TestBed.inject(ConfigService) as jasmine.SpyObj; dataService = TestBed.inject(DataService) as jasmine.SpyObj; projectService = TestBed.inject(ProjectService) as jasmine.SpyObj; @@ -136,7 +134,7 @@ describe('ChatbotComponent', () => { const assistantResponse = 'Hi there!'; component['userInput'] = userMessage; - awsBedRockService.sendMessage.and.returnValue(Promise.resolve(assistantResponse)); + chatService.sendMessage.and.returnValue(Promise.resolve(assistantResponse)); spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve('New Title')); await component['sendMessage'](); @@ -158,7 +156,7 @@ describe('ChatbotComponent', () => { // First user message (only system message exists initially) component['messages'] = []; - awsBedRockService.sendMessage.and.returnValue(Promise.resolve(assistantResponse)); + chatService.sendMessage.and.returnValue(Promise.resolve(assistantResponse)); spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve(newTitle)); await component['sendMessage'](); @@ -173,7 +171,7 @@ describe('ChatbotComponent', () => { await component['sendMessage'](); - expect(awsBedRockService.sendMessage).not.toHaveBeenCalled(); + expect(chatService.sendMessage).not.toHaveBeenCalled(); expect(component['messages'].length).toBe(0); }); @@ -183,7 +181,7 @@ describe('ChatbotComponent', () => { await component['sendMessage'](); - expect(awsBedRockService.sendMessage).not.toHaveBeenCalled(); + expect(chatService.sendMessage).not.toHaveBeenCalled(); }); it('should not send messages when no current chat', async () => { @@ -192,12 +190,12 @@ describe('ChatbotComponent', () => { await component['sendMessage'](); - expect(awsBedRockService.sendMessage).not.toHaveBeenCalled(); + expect(chatService.sendMessage).not.toHaveBeenCalled(); }); it('should handle errors when sending messages', async () => { component['userInput'] = 'Hello'; - awsBedRockService.sendMessage.and.returnValue(Promise.reject(new Error('API error'))); + chatService.sendMessage.and.returnValue(Promise.reject(new Error('API error'))); await component['sendMessage'](); @@ -338,7 +336,7 @@ describe('ChatbotComponent', () => { it('should send message on Enter key press', () => { component['userInput'] = 'Hello'; - awsBedRockService.sendMessage.and.returnValue(Promise.resolve('Response')); + chatService.sendMessage.and.returnValue(Promise.resolve('Response')); spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve('New Title')); const event = new KeyboardEvent('keypress', { key: 'Enter' }); @@ -358,7 +356,7 @@ describe('ChatbotComponent', () => { component['handleKeyPress'](event); expect(event.preventDefault).not.toHaveBeenCalled(); - expect(awsBedRockService.sendMessage).not.toHaveBeenCalled(); + expect(chatService.sendMessage).not.toHaveBeenCalled(); }); }); diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts index e9348306a43..7f9a14e6fc5 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts @@ -6,7 +6,6 @@ import { ConfigService } from '../../../services/configService'; import { CRaterService } from '../../../services/cRaterService'; import { ProjectService } from '../../../services/projectService'; import { SummaryService } from '../../../components/summary/summaryService'; -import { AwsBedRockChatService } from '../../../../../app/services/chat/awsBedRockChat.service'; import { TeacherDataService } from '../../../services/teacherDataService'; import { LocalStorageService } from '../../../../../app/services/localStorageService'; import { provideHttpClient } from '@angular/common/http'; @@ -14,11 +13,13 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { DataService } from '../../../../../app/services/data.service'; import { MarkdownComponent, MarkdownService } from 'ngx-markdown'; import { TeacherProjectService } from '../../../services/teacherProjectService'; +import { ChatService } from '../../../../../app/services/chat/chat.service'; +import { OpenAiChatService } from '../../../../../app/services/chat/openAiChat.service'; describe('OpenResponseAiSummaryComponent', () => { let component: OpenResponseAiSummaryComponent; let fixture: ComponentFixture; - let awsBedRockService: AwsBedRockChatService; + let chatService: ChatService; let localStorageService: LocalStorageService; let dataService: TeacherDataService; let projectService: ProjectService; @@ -32,11 +33,11 @@ describe('OpenResponseAiSummaryComponent', () => { { provide: DataService, useExisting: TeacherDataService }, MockProviders( AnnotationService, - AwsBedRockChatService, ConfigService, CRaterService, LocalStorageService, MarkdownService, + OpenAiChatService, TeacherProjectService, SummaryService, TeacherDataService @@ -44,7 +45,7 @@ describe('OpenResponseAiSummaryComponent', () => { ] }).compileComponents(); - awsBedRockService = TestBed.inject(AwsBedRockChatService); + chatService = TestBed.inject(OpenAiChatService); localStorageService = TestBed.inject(LocalStorageService); dataService = TestBed.inject(TeacherDataService); projectService = TestBed.inject(TeacherProjectService); @@ -164,8 +165,8 @@ describe('OpenResponseAiSummaryComponent', () => { fixture.detectChanges(); }); - it('should call awsBedRockService with correct system prompt', async () => { - const sendMessageSpy = spyOn(awsBedRockService, 'sendMessage').and.returnValue( + it('should call chatService with correct system prompt', async () => { + const sendMessageSpy = spyOn(chatService, 'sendMessage').and.returnValue( Promise.resolve('Generated summary') ); await component['generateSummary'](); @@ -174,8 +175,8 @@ describe('OpenResponseAiSummaryComponent', () => { expect(messages[0].content).toContain('What is your opinion on climate change?'); }); - it('should call awsBedRockService with student responses', async () => { - const sendMessageSpy = spyOn(awsBedRockService, 'sendMessage').and.returnValue( + it('should call chatService with student responses', async () => { + const sendMessageSpy = spyOn(chatService, 'sendMessage').and.returnValue( Promise.resolve('Generated summary') ); await component['generateSummary'](); @@ -187,7 +188,7 @@ describe('OpenResponseAiSummaryComponent', () => { it('should save summary to localStorage', async () => { const generatedSummary = 'This is a generated summary'; - spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve(generatedSummary)); + spyOn(chatService, 'sendMessage').and.returnValue(Promise.resolve(generatedSummary)); const setItemSpy = spyOn(localStorageService, 'setItem'); await component['generateSummary'](); expect(setItemSpy).toHaveBeenCalledWith( @@ -197,7 +198,7 @@ describe('OpenResponseAiSummaryComponent', () => { }); it('should save timestamp to localStorage', async () => { - spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + spyOn(chatService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); const setItemSpy = spyOn(localStorageService, 'setItem'); const beforeTime = new Date().getTime(); await component['generateSummary'](); @@ -209,21 +210,21 @@ describe('OpenResponseAiSummaryComponent', () => { }); it('should set generatingSummary to false after completion', async () => { - spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + spyOn(chatService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); await component['generateSummary'](); expect(component['generatingSummary']).toBe(false); }); it('should set newSummaryAvailable to false after generation', async () => { component['newSummaryAvailable'] = true; - spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + spyOn(chatService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); await component['generateSummary'](); expect(component['newSummaryAvailable']).toBe(false); }); it('should update summary property', async () => { const generatedSummary = 'This is a generated summary'; - spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve(generatedSummary)); + spyOn(chatService, 'sendMessage').and.returnValue(Promise.resolve(generatedSummary)); await component['generateSummary'](); expect(component['summary']).toBe(generatedSummary); }); From 1db69cd58db3985fa14f131f860893e7f124800e Mon Sep 17 00:00:00 2001 From: Jonathan Lim-Breitbart Date: Tue, 24 Mar 2026 14:16:20 -0700 Subject: [PATCH 14/17] Updated styles; only show AI summary if AI is enabled --- .../component-summary.component.html | 52 ++++++++++--------- .../component-summary.component.scss | 2 +- .../component-summary.component.ts | 4 +- 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html index 3733027bc3b..811a5303fa2 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html @@ -93,35 +93,37 @@ class="summary" /> - } @else if (component?.type === 'OpenResponse' && hasStudentWork) { - - - + } @else if (component?.type === 'Discussion' && hasStudentWork) { +
+ @if (aiEnabled) { + - - - } @else if (component?.type === 'Discussion' && hasStudentWork) { -
- - - + } +
+ + +
}
diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.scss b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.scss index aab4879d954..6173a86df75 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.scss +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.scss @@ -27,7 +27,7 @@ peer-group-button > * { } .summary { - @apply rounded-md p-3 overflow-hidden bg-white block; + @apply rounded-md p-3 overflow-hidden bg-white flex flex-col grow; } .summary-graph { diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts index c763245b586..1cca40fc2ce 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts @@ -49,6 +49,7 @@ import { DiscussionSummaryComponent } from '../../../directives/teacher-summary- templateUrl: './component-summary.component.html' }) export class ComponentSummaryComponent { + protected aiEnabled: boolean; protected avgScore: number; @Input() component: ComponentContent; protected hasCorrectAnswer: boolean; @@ -94,13 +95,14 @@ export class ComponentSummaryComponent { this.hasIdeaRubricData = this.cRaterService .getCRaterRubric(this.node.id, this.component.id) .hasRubricData(); + this.aiEnabled = this.projectService.getProject().ai?.enabled; this.hasSummaryData = (this.component?.type === 'MultipleChoice' && this.hasStudentWork) || (this.hasScoresSummary && this.hasScoreAnnotation) || this.hasIdeaRubricData || ['Match', 'Discussion'].includes(this.component?.type); if (this.component?.type === 'OpenResponse') { - this.hasSummaryData = this.projectService.getProject().ai?.enabled; + this.hasSummaryData = this.aiEnabled; } } From ac7de6a514273a1ee03ee9f2ffb2e7917fd2efa1 Mon Sep 17 00:00:00 2001 From: Jonathan Lim-Breitbart Date: Tue, 24 Mar 2026 14:16:46 -0700 Subject: [PATCH 15/17] Add title to summary dialog component collapse button --- .../component-summary/summary-dialog.component.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/summary-dialog.component.html b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/summary-dialog.component.html index 001f1cb7f1d..c5b4f160e30 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/summary-dialog.component.html +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/summary-dialog.component.html @@ -2,6 +2,8 @@ - @if (generatingSummary) { - - } - - @if (newSummaryAvailable) { - *New responses since last summary +
+
+ + @if (!hasStudentResponses) { + No student responses + } + @if (generatingSummary) { + }
- @if (summary) { - -
- Summary generated {{ summaryDate | date: 'short' }} from - {{ latestComponentStates.length }} responses -
+ @if (newSummaryAvailable) { + *New responses since last summary } -} @else { -
No student responses
+
+@if (summary) { + +
{{ summaryCaption }}
} diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts index e6ffea6cdd0..12916ebcf82 100644 --- a/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts @@ -15,7 +15,8 @@ import { OpenAiChatService } from '../../../../../app/services/chat/openAiChat.s * Abstract base class for components that use an LLM to summarize student responses. */ @Component({ - imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], + imports: [MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], + providers: [DatePipe], templateUrl: './ai-summary.component.html' }) export abstract class AiSummaryComponent { @@ -25,6 +26,7 @@ export abstract class AiSummaryComponent { private chatService: ChatService = inject(OpenAiChatService); protected dataService: TeacherDataService = inject(TeacherDataService); + protected datePipe: DatePipe = inject(DatePipe); private localStorageService: LocalStorageService = inject(LocalStorageService); protected projectService: TeacherProjectService = inject(TeacherProjectService); @@ -86,4 +88,8 @@ export abstract class AiSummaryComponent { private getSummaryTimeKey(): string { return `component-summary-time-${this.periodId}-${this.nodeId}-${this.componentId}`; } + + protected get summaryCaption(): string { + return $localize`Summary generated ${this.datePipe.transform(this.summaryDate, 'short')} from ${this.latestComponentStates.length} responses`; + } } diff --git a/src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts b/src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts index c1fb900838d..c3727aef2f2 100644 --- a/src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts @@ -1,10 +1,11 @@ +import { DatePipe } from '@angular/common'; import { Component } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { MarkdownComponent } from 'ngx-markdown'; import { AiSummaryComponent } from '../ai-summary/ai-summary.component'; -import { DatePipe } from '@angular/common'; interface Thread { id: number; @@ -16,7 +17,8 @@ interface Thread { * Uses an LLM to summarize student discussion threads. */ @Component({ - imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], + imports: [MarkdownComponent, MatButton, MatIcon, MatProgressSpinner, MatTooltipModule], + providers: [DatePipe], selector: 'discussion-ai-summary', templateUrl: '../ai-summary/ai-summary.component.html' }) @@ -48,4 +50,8 @@ export class DiscussionAiSummaryComponent extends AiSummaryComponent { }); return threads; } + + protected override get summaryCaption(): string { + return $localize`Summary generated ${this.datePipe.transform(this.summaryDate, 'short')} from ${this.latestComponentStates.length} posts and comments`; + } } diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts index 7f9a14e6fc5..7bbffdebf0d 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts @@ -264,7 +264,7 @@ describe('OpenResponseAiSummaryComponent', () => { fixture.detectChanges(); const button = fixture.nativeElement.querySelector('button'); expect(button).toBeTruthy(); - expect(button.textContent).toContain('Generate Class Summary'); + expect(button.textContent).toContain('Generate Summary'); }); it('should disable generate button when generatingSummary is true', () => { diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts index 42a914263a4..6953cf4b80e 100644 --- a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts @@ -3,6 +3,7 @@ import { Component } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { MarkdownComponent } from 'ngx-markdown'; import { AiSummaryComponent } from '../ai-summary/ai-summary.component'; @@ -10,7 +11,8 @@ import { AiSummaryComponent } from '../ai-summary/ai-summary.component'; * Uses an LLM to summarize students' responses to open response questions. */ @Component({ - imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], + imports: [MarkdownComponent, MatButton, MatIcon, MatProgressSpinner, MatTooltipModule], + providers: [DatePipe], selector: 'open-response-ai-summary', templateUrl: '../ai-summary/ai-summary.component.html' }) From ba1ce92f8e3ff512b789b1de5828ed0342826e96 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 24 Mar 2026 21:27:48 +0000 Subject: [PATCH 17/17] Updated messages --- src/messages.xlf | 61 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/src/messages.xlf b/src/messages.xlf index a63098a0882..cb4e8c57754 100644 --- a/src/messages.xlf +++ b/src/messages.xlf @@ -13844,6 +13844,13 @@ The branches will be removed but the steps will remain in the unit. 5,9
+ + Collapse summary + + src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/summary-dialog.component.html + 5,8 + + No feedback given for this version @@ -22554,64 +22561,78 @@ If this problem continues, let your teacher know and move on to the next activit 401 - - Generate Class Summary + + Summarize class responses using AI src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 5,7 + 9,13 src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 5,7 + 9,13 src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 5,7 + 9,13 - - *New responses since last summary + + Generate Summary src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 12,16 + 13,15 src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 12,16 + 13,15 src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 12,16 + 13,15 - - Summary generated from responses + + No student responses src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 18,22 + 16,19 src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 18,22 + 16,19 src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 18,22 + 16,19 - - No student responses + + *New responses since last summary src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 23,25 + 23,28 src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 23,25 + 23,28 src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html - 23,25 + 23,28 + + + + Summary generated from responses + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts + 93 + + + + Summary generated from posts and comments + + src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts + 55