From 1e1682fa6a3f70c86e36e9385e8d396d554c2409 Mon Sep 17 00:00:00 2001 From: Hiroki Terashima Date: Tue, 27 Jan 2026 13:49:19 -0800 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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