From 92780da99b0cdce24e178474d6f8d010c18f6e81 Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Thu, 4 Jun 2026 10:26:50 +0200 Subject: [PATCH 1/2] admin page for setting up the ai review guidlines --- Client/src/app/app.routes.ts | 7 + .../side-bar/side-bar.component.html | 10 + .../components/side-bar/side-bar.component.ts | 3 +- .../modules/openapi/.openapi-generator/FILES | 5 + .../ai-review-guideline-controller.service.ts | 434 ++++++++++++++++++ ...w-guideline-controller.serviceInterface.ts | 62 +++ .../src/app/core/modules/openapi/api/api.ts | 5 +- .../openapi/model/ai-review-guideline-dto.ts | 60 +++ .../model/create-ai-review-guideline-dto.ts | 53 +++ .../openapi/model/feedback-compact-dto.ts | 5 +- .../core/modules/openapi/model/feedback.ts | 7 +- .../app/core/modules/openapi/model/models.ts | 3 + .../model/module-version-view-feedback-dto.ts | 5 +- .../model/update-ai-review-guideline-dto.ts | 53 +++ .../openapi/model/update-user-role-dto.ts | 5 +- .../core/modules/openapi/model/user-dto.ts | 5 +- .../app/core/modules/openapi/model/user.ts | 5 +- .../ai-review-guideline-manager.guard.ts | 27 ++ .../shared/proposal-review-section.utils.ts | 38 ++ Client/src/app/core/shared/user-role.utils.ts | 4 + .../ai-review-guidelines-page.component.html | 96 ++++ .../ai-review-guidelines-page.component.ts | 160 +++++++ .../src/app/pages/index/index.component.html | 6 + Client/src/app/pages/index/index.component.ts | 3 +- README.md | 18 +- Server/gradle.properties | 3 + .../AiReviewGuidelineController.java | 57 +++ .../ls1/dtos/AiReviewGuidelineDTO.java | 55 +++ .../ls1/dtos/CreateAiReviewGuidelineDTO.java | 22 + .../ls1/dtos/UpdateAiReviewGuidelineDTO.java | 22 + .../ls1/enums/ProposalReviewSection.java | 37 ++ .../modulemanagement/ls1/enums/UserRole.java | 4 +- .../ls1/models/AiReviewGuideline.java | 49 ++ .../AiReviewGuidelineRepository.java | 14 + .../services/AiReviewGuidelineService.java | 74 +++ .../shared/AiReviewGuidelinePromptUtil.java | 159 +++++++ .../changes/0017_ai_review_guidelines.yaml | 75 +++ .../main/resources/db/changelog/master.yaml | 3 + 38 files changed, 1634 insertions(+), 19 deletions(-) create mode 100644 Client/src/app/core/modules/openapi/api/ai-review-guideline-controller.service.ts create mode 100644 Client/src/app/core/modules/openapi/api/ai-review-guideline-controller.serviceInterface.ts create mode 100644 Client/src/app/core/modules/openapi/model/ai-review-guideline-dto.ts create mode 100644 Client/src/app/core/modules/openapi/model/create-ai-review-guideline-dto.ts create mode 100644 Client/src/app/core/modules/openapi/model/update-ai-review-guideline-dto.ts create mode 100644 Client/src/app/core/security/ai-review-guideline-manager.guard.ts create mode 100644 Client/src/app/core/shared/proposal-review-section.utils.ts create mode 100644 Client/src/app/pages/ai-review-guidelines/ai-review-guidelines-page.component.html create mode 100644 Client/src/app/pages/ai-review-guidelines/ai-review-guidelines-page.component.ts create mode 100644 Server/gradle.properties create mode 100644 Server/src/main/java/modulemanagement/ls1/controllers/AiReviewGuidelineController.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/AiReviewGuidelineDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/CreateAiReviewGuidelineDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/UpdateAiReviewGuidelineDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/enums/ProposalReviewSection.java create mode 100644 Server/src/main/java/modulemanagement/ls1/models/AiReviewGuideline.java create mode 100644 Server/src/main/java/modulemanagement/ls1/repositories/AiReviewGuidelineRepository.java create mode 100644 Server/src/main/java/modulemanagement/ls1/services/AiReviewGuidelineService.java create mode 100644 Server/src/main/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtil.java create mode 100644 Server/src/main/resources/db/changelog/changes/0017_ai_review_guidelines.yaml diff --git a/Client/src/app/app.routes.ts b/Client/src/app/app.routes.ts index e37bfa2f..4ba329d5 100644 --- a/Client/src/app/app.routes.ts +++ b/Client/src/app/app.routes.ts @@ -19,6 +19,8 @@ import { DegreeProgramDetailsPageComponent } from './pages/admin/degree-programs import { AllSpecializationsPageComponent } from './pages/admin/degree-program-specializations/all-specializations-page.component'; import { ExaminationBoardDetailPageComponent } from './pages/admin/examination-boards/examination-board-detail-page.component'; import { ExaminationBoardsPageComponent } from './pages/admin/examination-boards/examination-boards-page.component'; +import { AiReviewGuidelinesPageComponent } from './pages/ai-review-guidelines/ai-review-guidelines-page.component'; +import { AiReviewGuidelineManagerGuard } from './core/security/ai-review-guideline-manager.guard'; export const routes: Routes = [ { path: '', component: IndexComponent }, { @@ -52,6 +54,11 @@ export const routes: Routes = [ { path: '', redirectTo: 'information', pathMatch: 'full' } ] }, + { + path: 'ai-review-guidelines', + component: AiReviewGuidelinesPageComponent, + canActivate: [AuthGuard, AiReviewGuidelineManagerGuard] + }, { path: 'admin', canActivate: [AuthGuard, AdminGuard], diff --git a/Client/src/app/components/side-bar/side-bar.component.html b/Client/src/app/components/side-bar/side-bar.component.html index 4aba109a..bd3d1bd8 100644 --- a/Client/src/app/components/side-bar/side-bar.component.html +++ b/Client/src/app/components/side-bar/side-bar.component.html @@ -44,6 +44,16 @@ styleClass="sidebar-btn justify-start" /> } + @if (isAiReviewGuidelineManager()) { + + } @if (isReviewer()) { isAdminRole(this.user()?.roles); isProfessor = (): boolean => isProfessorRole(this.user()?.roles); isReviewer = (): boolean => isReviewerRole(this.user()?.roles); + isAiReviewGuidelineManager = (): boolean => isAiReviewGuidelineManagerRole(this.user()?.roles); isActive(path: string): boolean { return this.router.url.startsWith(path); diff --git a/Client/src/app/core/modules/openapi/.openapi-generator/FILES b/Client/src/app/core/modules/openapi/.openapi-generator/FILES index 2e877374..bf8ebc57 100644 --- a/Client/src/app/core/modules/openapi/.openapi-generator/FILES +++ b/Client/src/app/core/modules/openapi/.openapi-generator/FILES @@ -4,6 +4,8 @@ README.md api.module.ts api/admin-user-controller.service.ts api/admin-user-controller.serviceInterface.ts +api/ai-review-guideline-controller.service.ts +api/ai-review-guideline-controller.serviceInterface.ts api/api.ts api/degree-program-specializations-controller.service.ts api/degree-program-specializations-controller.serviceInterface.ts @@ -24,8 +26,10 @@ encoder.ts git_push.sh index.ts model/add-specializations-to-degree-program-dto.ts +model/ai-review-guideline-dto.ts model/completion-service-request-dto.ts model/completion-service-response-dto.ts +model/create-ai-review-guideline-dto.ts model/create-degree-program-dto.ts model/create-degree-program-specialization-dto.ts model/create-examination-board-dto.ts @@ -50,6 +54,7 @@ model/proposal-view-dto.ts model/proposals-compact-dto.ts model/responsible-user-dto.ts model/similar-module-dto.ts +model/update-ai-review-guideline-dto.ts model/update-degree-program-dto.ts model/update-degree-program-specialization-dto.ts model/update-examination-board-dto.ts diff --git a/Client/src/app/core/modules/openapi/api/ai-review-guideline-controller.service.ts b/Client/src/app/core/modules/openapi/api/ai-review-guideline-controller.service.ts new file mode 100644 index 00000000..a26d6856 --- /dev/null +++ b/Client/src/app/core/modules/openapi/api/ai-review-guideline-controller.service.ts @@ -0,0 +1,434 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpParameterCodec, HttpContext + } from '@angular/common/http'; +import { CustomHttpParameterCodec } from '../encoder'; +import { Observable } from 'rxjs'; + +// @ts-ignore +import { AiReviewGuidelineDTO } from '../model/ai-review-guideline-dto'; +// @ts-ignore +import { CreateAiReviewGuidelineDTO } from '../model/create-ai-review-guideline-dto'; +// @ts-ignore +import { UpdateAiReviewGuidelineDTO } from '../model/update-ai-review-guideline-dto'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; +import { + AiReviewGuidelineControllerServiceInterface +} from './ai-review-guideline-controller.serviceInterface'; + + + +@Injectable({ + providedIn: 'root' +}) +export class AiReviewGuidelineControllerService implements AiReviewGuidelineControllerServiceInterface { + + protected basePath = 'http://localhost:8080'; + public defaultHeaders = new HttpHeaders(); + public configuration = new Configuration(); + public encoder: HttpParameterCodec; + + constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: Configuration) { + if (configuration) { + this.configuration = configuration; + } + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + + // @ts-ignore + private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams { + if (typeof value === "object" && value instanceof Date === false) { + httpParams = this.addToHttpParamsRecursive(httpParams, value); + } else { + httpParams = this.addToHttpParamsRecursive(httpParams, value, key); + } + return httpParams; + } + + private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams { + if (value == null) { + return httpParams; + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + (value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)); + } else if (value instanceof Date) { + if (key != null) { + httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10)); + } else { + throw Error("key may not be null if value is Date"); + } + } else { + Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive( + httpParams, value[k], key != null ? `${key}.${k}` : k)); + } + } else if (key != null) { + httpParams = httpParams.append(key, value); + } else { + throw Error("key may not be null if value is not object or array"); + } + return httpParams; + } + + /** + * @param createAiReviewGuidelineDTO + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public createGuideline(createAiReviewGuidelineDTO: CreateAiReviewGuidelineDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public createGuideline(createAiReviewGuidelineDTO: CreateAiReviewGuidelineDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createGuideline(createAiReviewGuidelineDTO: CreateAiReviewGuidelineDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createGuideline(createAiReviewGuidelineDTO: CreateAiReviewGuidelineDTO, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (createAiReviewGuidelineDTO === null || createAiReviewGuidelineDTO === undefined) { + throw new Error('Required parameter createAiReviewGuidelineDTO was null or undefined when calling createGuideline.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/ai-review-guidelines`; + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: createAiReviewGuidelineDTO, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param id + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public deleteGuideline(id: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable; + public deleteGuideline(id: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable>; + public deleteGuideline(id: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable>; + public deleteGuideline(id: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling deleteGuideline.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/ai-review-guidelines/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('delete', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getAllGuidelines(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getAllGuidelines(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getAllGuidelines(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getAllGuidelines(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/ai-review-guidelines`; + return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param id + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getGuideline(id: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getGuideline(id: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getGuideline(id: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getGuideline(id: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling getGuideline.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/ai-review-guidelines/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param id + * @param updateAiReviewGuidelineDTO + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public updateGuideline(id: number, updateAiReviewGuidelineDTO: UpdateAiReviewGuidelineDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public updateGuideline(id: number, updateAiReviewGuidelineDTO: UpdateAiReviewGuidelineDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateGuideline(id: number, updateAiReviewGuidelineDTO: UpdateAiReviewGuidelineDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateGuideline(id: number, updateAiReviewGuidelineDTO: UpdateAiReviewGuidelineDTO, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling updateGuideline.'); + } + if (updateAiReviewGuidelineDTO === null || updateAiReviewGuidelineDTO === undefined) { + throw new Error('Required parameter updateAiReviewGuidelineDTO was null or undefined when calling updateGuideline.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/ai-review-guidelines/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('put', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: updateAiReviewGuidelineDTO, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + +} diff --git a/Client/src/app/core/modules/openapi/api/ai-review-guideline-controller.serviceInterface.ts b/Client/src/app/core/modules/openapi/api/ai-review-guideline-controller.serviceInterface.ts new file mode 100644 index 00000000..babad333 --- /dev/null +++ b/Client/src/app/core/modules/openapi/api/ai-review-guideline-controller.serviceInterface.ts @@ -0,0 +1,62 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { HttpHeaders } from '@angular/common/http'; + +import { Observable } from 'rxjs'; + +import { AiReviewGuidelineDTO } from '../model/models'; +import { CreateAiReviewGuidelineDTO } from '../model/models'; +import { UpdateAiReviewGuidelineDTO } from '../model/models'; + + +import { Configuration } from '../configuration'; + + + +export interface AiReviewGuidelineControllerServiceInterface { + defaultHeaders: HttpHeaders; + configuration: Configuration; + + /** + * + * + * @param createAiReviewGuidelineDTO + */ + createGuideline(createAiReviewGuidelineDTO: CreateAiReviewGuidelineDTO, extraHttpRequestParams?: any): Observable; + + /** + * + * + * @param id + */ + deleteGuideline(id: number, extraHttpRequestParams?: any): Observable<{}>; + + /** + * + * + */ + getAllGuidelines(extraHttpRequestParams?: any): Observable>; + + /** + * + * + * @param id + */ + getGuideline(id: number, extraHttpRequestParams?: any): Observable; + + /** + * + * + * @param id + * @param updateAiReviewGuidelineDTO + */ + updateGuideline(id: number, updateAiReviewGuidelineDTO: UpdateAiReviewGuidelineDTO, extraHttpRequestParams?: any): Observable; + +} diff --git a/Client/src/app/core/modules/openapi/api/api.ts b/Client/src/app/core/modules/openapi/api/api.ts index 1f2dad9c..69891e4a 100644 --- a/Client/src/app/core/modules/openapi/api/api.ts +++ b/Client/src/app/core/modules/openapi/api/api.ts @@ -1,6 +1,9 @@ export * from './admin-user-controller.service'; import { AdminUserControllerService } from './admin-user-controller.service'; export * from './admin-user-controller.serviceInterface'; +export * from './ai-review-guideline-controller.service'; +import { AiReviewGuidelineControllerService } from './ai-review-guideline-controller.service'; +export * from './ai-review-guideline-controller.serviceInterface'; export * from './degree-program-specializations-controller.service'; import { DegreeProgramSpecializationsControllerService } from './degree-program-specializations-controller.service'; export * from './degree-program-specializations-controller.serviceInterface'; @@ -22,4 +25,4 @@ export * from './proposal-controller.serviceInterface'; export * from './user-controller.service'; import { UserControllerService } from './user-controller.service'; export * from './user-controller.serviceInterface'; -export const APIS = [AdminUserControllerService, DegreeProgramSpecializationsControllerService, DegreeProgramsControllerService, ExaminationBoardControllerService, FeedbackControllerService, ModuleVersionControllerService, ProposalControllerService, UserControllerService]; +export const APIS = [AdminUserControllerService, AiReviewGuidelineControllerService, DegreeProgramSpecializationsControllerService, DegreeProgramsControllerService, ExaminationBoardControllerService, FeedbackControllerService, ModuleVersionControllerService, ProposalControllerService, UserControllerService]; diff --git a/Client/src/app/core/modules/openapi/model/ai-review-guideline-dto.ts b/Client/src/app/core/modules/openapi/model/ai-review-guideline-dto.ts new file mode 100644 index 00000000..50963ee7 --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/ai-review-guideline-dto.ts @@ -0,0 +1,60 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface AiReviewGuidelineDTO { + guidelineId?: number; + section?: AiReviewGuidelineDTO.SectionEnum; + title?: string; + instruction?: string; + sortOrder?: number; + createdByUserId?: string; + createdByDisplayName?: string; + updatedByUserId?: string; + updatedByDisplayName?: string; + createdAt?: string; + updatedAt?: string; +} +export namespace AiReviewGuidelineDTO { + export type SectionEnum = 'GENERAL' | 'TITLE_ENG' | 'TITLE_DE' | 'LEVEL_ENG' | 'LANGUAGE_ENG' | 'FREQUENCY_ENG' | 'CREDITS' | 'DURATION' | 'HOURS_LECTURE' | 'HOURS_EXERCISE' | 'HOURS_PRACTICAL' | 'HOURS_SEMINAR' | 'FIRST_SEMESTER_AVAILABLE' | 'SUCCESSOR_MODULE_NAME' | 'HOURS_TOTAL' | 'HOURS_SELF_STUDY' | 'HOURS_PRESENCE' | 'BULLET_POINTS' | 'EXAMINATION_ACHIEVEMENTS' | 'REPETITION' | 'RECOMMENDED_PREREQUISITES' | 'CONTENT' | 'LEARNING_OUTCOMES' | 'TEACHING_METHODS' | 'MEDIA' | 'LITERATURE' | 'RESPONSIBLES' | 'LV_SWS_LECTURER' | 'DEGREE_PROGRAM_ASSIGNMENTS'; + export const SectionEnum = { + General: 'GENERAL' as SectionEnum, + TitleEng: 'TITLE_ENG' as SectionEnum, + TitleDe: 'TITLE_DE' as SectionEnum, + LevelEng: 'LEVEL_ENG' as SectionEnum, + LanguageEng: 'LANGUAGE_ENG' as SectionEnum, + FrequencyEng: 'FREQUENCY_ENG' as SectionEnum, + Credits: 'CREDITS' as SectionEnum, + Duration: 'DURATION' as SectionEnum, + HoursLecture: 'HOURS_LECTURE' as SectionEnum, + HoursExercise: 'HOURS_EXERCISE' as SectionEnum, + HoursPractical: 'HOURS_PRACTICAL' as SectionEnum, + HoursSeminar: 'HOURS_SEMINAR' as SectionEnum, + FirstSemesterAvailable: 'FIRST_SEMESTER_AVAILABLE' as SectionEnum, + SuccessorModuleName: 'SUCCESSOR_MODULE_NAME' as SectionEnum, + HoursTotal: 'HOURS_TOTAL' as SectionEnum, + HoursSelfStudy: 'HOURS_SELF_STUDY' as SectionEnum, + HoursPresence: 'HOURS_PRESENCE' as SectionEnum, + BulletPoints: 'BULLET_POINTS' as SectionEnum, + ExaminationAchievements: 'EXAMINATION_ACHIEVEMENTS' as SectionEnum, + Repetition: 'REPETITION' as SectionEnum, + RecommendedPrerequisites: 'RECOMMENDED_PREREQUISITES' as SectionEnum, + Content: 'CONTENT' as SectionEnum, + LearningOutcomes: 'LEARNING_OUTCOMES' as SectionEnum, + TeachingMethods: 'TEACHING_METHODS' as SectionEnum, + Media: 'MEDIA' as SectionEnum, + Literature: 'LITERATURE' as SectionEnum, + Responsibles: 'RESPONSIBLES' as SectionEnum, + LvSwsLecturer: 'LV_SWS_LECTURER' as SectionEnum, + DegreeProgramAssignments: 'DEGREE_PROGRAM_ASSIGNMENTS' as SectionEnum + }; +} + + diff --git a/Client/src/app/core/modules/openapi/model/create-ai-review-guideline-dto.ts b/Client/src/app/core/modules/openapi/model/create-ai-review-guideline-dto.ts new file mode 100644 index 00000000..07c950b4 --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/create-ai-review-guideline-dto.ts @@ -0,0 +1,53 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface CreateAiReviewGuidelineDTO { + section: CreateAiReviewGuidelineDTO.SectionEnum; + title: string; + instruction: string; + sortOrder?: number; +} +export namespace CreateAiReviewGuidelineDTO { + export type SectionEnum = 'GENERAL' | 'TITLE_ENG' | 'TITLE_DE' | 'LEVEL_ENG' | 'LANGUAGE_ENG' | 'FREQUENCY_ENG' | 'CREDITS' | 'DURATION' | 'HOURS_LECTURE' | 'HOURS_EXERCISE' | 'HOURS_PRACTICAL' | 'HOURS_SEMINAR' | 'FIRST_SEMESTER_AVAILABLE' | 'SUCCESSOR_MODULE_NAME' | 'HOURS_TOTAL' | 'HOURS_SELF_STUDY' | 'HOURS_PRESENCE' | 'BULLET_POINTS' | 'EXAMINATION_ACHIEVEMENTS' | 'REPETITION' | 'RECOMMENDED_PREREQUISITES' | 'CONTENT' | 'LEARNING_OUTCOMES' | 'TEACHING_METHODS' | 'MEDIA' | 'LITERATURE' | 'RESPONSIBLES' | 'LV_SWS_LECTURER' | 'DEGREE_PROGRAM_ASSIGNMENTS'; + export const SectionEnum = { + General: 'GENERAL' as SectionEnum, + TitleEng: 'TITLE_ENG' as SectionEnum, + TitleDe: 'TITLE_DE' as SectionEnum, + LevelEng: 'LEVEL_ENG' as SectionEnum, + LanguageEng: 'LANGUAGE_ENG' as SectionEnum, + FrequencyEng: 'FREQUENCY_ENG' as SectionEnum, + Credits: 'CREDITS' as SectionEnum, + Duration: 'DURATION' as SectionEnum, + HoursLecture: 'HOURS_LECTURE' as SectionEnum, + HoursExercise: 'HOURS_EXERCISE' as SectionEnum, + HoursPractical: 'HOURS_PRACTICAL' as SectionEnum, + HoursSeminar: 'HOURS_SEMINAR' as SectionEnum, + FirstSemesterAvailable: 'FIRST_SEMESTER_AVAILABLE' as SectionEnum, + SuccessorModuleName: 'SUCCESSOR_MODULE_NAME' as SectionEnum, + HoursTotal: 'HOURS_TOTAL' as SectionEnum, + HoursSelfStudy: 'HOURS_SELF_STUDY' as SectionEnum, + HoursPresence: 'HOURS_PRESENCE' as SectionEnum, + BulletPoints: 'BULLET_POINTS' as SectionEnum, + ExaminationAchievements: 'EXAMINATION_ACHIEVEMENTS' as SectionEnum, + Repetition: 'REPETITION' as SectionEnum, + RecommendedPrerequisites: 'RECOMMENDED_PREREQUISITES' as SectionEnum, + Content: 'CONTENT' as SectionEnum, + LearningOutcomes: 'LEARNING_OUTCOMES' as SectionEnum, + TeachingMethods: 'TEACHING_METHODS' as SectionEnum, + Media: 'MEDIA' as SectionEnum, + Literature: 'LITERATURE' as SectionEnum, + Responsibles: 'RESPONSIBLES' as SectionEnum, + LvSwsLecturer: 'LV_SWS_LECTURER' as SectionEnum, + DegreeProgramAssignments: 'DEGREE_PROGRAM_ASSIGNMENTS' as SectionEnum + }; +} + + diff --git a/Client/src/app/core/modules/openapi/model/feedback-compact-dto.ts b/Client/src/app/core/modules/openapi/model/feedback-compact-dto.ts index c1d2b3b1..ab67efd0 100644 --- a/Client/src/app/core/modules/openapi/model/feedback-compact-dto.ts +++ b/Client/src/app/core/modules/openapi/model/feedback-compact-dto.ts @@ -19,7 +19,7 @@ export interface FeedbackCompactDTO { invalidated?: boolean; } export namespace FeedbackCompactDTO { - export type RequiredRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; + export type RequiredRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR' | 'AI_REVIEW_GUIDELINE_MANAGER'; export const RequiredRoleEnum = { Admin: 'ADMIN' as RequiredRoleEnum, QualityManagement: 'QUALITY_MANAGEMENT' as RequiredRoleEnum, @@ -27,7 +27,8 @@ export namespace FeedbackCompactDTO { ExaminationBoard: 'EXAMINATION_BOARD' as RequiredRoleEnum, Professor: 'PROFESSOR' as RequiredRoleEnum, ProgramCoordinator: 'PROGRAM_COORDINATOR' as RequiredRoleEnum, - SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RequiredRoleEnum + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RequiredRoleEnum, + AiReviewGuidelineManager: 'AI_REVIEW_GUIDELINE_MANAGER' as RequiredRoleEnum }; export type StatusEnum = 'PENDING_FEEDBACK' | 'APPROVED' | 'FEEDBACK_GIVEN' | 'REJECTED'; export const StatusEnum = { diff --git a/Client/src/app/core/modules/openapi/model/feedback.ts b/Client/src/app/core/modules/openapi/model/feedback.ts index c6f72d73..83a6410f 100644 --- a/Client/src/app/core/modules/openapi/model/feedback.ts +++ b/Client/src/app/core/modules/openapi/model/feedback.ts @@ -73,12 +73,12 @@ export interface Feedback { responsiblesAccepted?: boolean; lvSwsLecturerFeedback?: string; lvSwsLecturerAccepted?: boolean; - allFeedbackPositive?: boolean; feedbackGiven?: boolean; + allFeedbackPositive?: boolean; comment?: string; } export namespace Feedback { - export type RequiredRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; + export type RequiredRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR' | 'AI_REVIEW_GUIDELINE_MANAGER'; export const RequiredRoleEnum = { Admin: 'ADMIN' as RequiredRoleEnum, QualityManagement: 'QUALITY_MANAGEMENT' as RequiredRoleEnum, @@ -86,7 +86,8 @@ export namespace Feedback { ExaminationBoard: 'EXAMINATION_BOARD' as RequiredRoleEnum, Professor: 'PROFESSOR' as RequiredRoleEnum, ProgramCoordinator: 'PROGRAM_COORDINATOR' as RequiredRoleEnum, - SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RequiredRoleEnum + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RequiredRoleEnum, + AiReviewGuidelineManager: 'AI_REVIEW_GUIDELINE_MANAGER' as RequiredRoleEnum }; export type StatusEnum = 'PENDING_FEEDBACK' | 'APPROVED' | 'FEEDBACK_GIVEN' | 'REJECTED'; export const StatusEnum = { diff --git a/Client/src/app/core/modules/openapi/model/models.ts b/Client/src/app/core/modules/openapi/model/models.ts index 23bd5f0d..2a071cd2 100644 --- a/Client/src/app/core/modules/openapi/model/models.ts +++ b/Client/src/app/core/modules/openapi/model/models.ts @@ -1,6 +1,8 @@ export * from './add-specializations-to-degree-program-dto'; +export * from './ai-review-guideline-dto'; export * from './completion-service-request-dto'; export * from './completion-service-response-dto'; +export * from './create-ai-review-guideline-dto'; export * from './create-degree-program-dto'; export * from './create-degree-program-specialization-dto'; export * from './create-examination-board-dto'; @@ -24,6 +26,7 @@ export * from './proposal-view-dto'; export * from './proposals-compact-dto'; export * from './responsible-user-dto'; export * from './similar-module-dto'; +export * from './update-ai-review-guideline-dto'; export * from './update-degree-program-dto'; export * from './update-degree-program-specialization-dto'; export * from './update-examination-board-dto'; diff --git a/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts b/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts index 9db0bde0..7a42a939 100644 --- a/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts +++ b/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts @@ -52,7 +52,7 @@ export interface ModuleVersionViewFeedbackDTO { lvSwsLecturerFeedback?: string; } export namespace ModuleVersionViewFeedbackDTO { - export type RequiredRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; + export type RequiredRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR' | 'AI_REVIEW_GUIDELINE_MANAGER'; export const RequiredRoleEnum = { Admin: 'ADMIN' as RequiredRoleEnum, QualityManagement: 'QUALITY_MANAGEMENT' as RequiredRoleEnum, @@ -60,7 +60,8 @@ export namespace ModuleVersionViewFeedbackDTO { ExaminationBoard: 'EXAMINATION_BOARD' as RequiredRoleEnum, Professor: 'PROFESSOR' as RequiredRoleEnum, ProgramCoordinator: 'PROGRAM_COORDINATOR' as RequiredRoleEnum, - SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RequiredRoleEnum + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RequiredRoleEnum, + AiReviewGuidelineManager: 'AI_REVIEW_GUIDELINE_MANAGER' as RequiredRoleEnum }; export type FeedbackStatusEnum = 'PENDING_FEEDBACK' | 'APPROVED' | 'FEEDBACK_GIVEN' | 'REJECTED'; export const FeedbackStatusEnum = { diff --git a/Client/src/app/core/modules/openapi/model/update-ai-review-guideline-dto.ts b/Client/src/app/core/modules/openapi/model/update-ai-review-guideline-dto.ts new file mode 100644 index 00000000..65834df3 --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/update-ai-review-guideline-dto.ts @@ -0,0 +1,53 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface UpdateAiReviewGuidelineDTO { + section: UpdateAiReviewGuidelineDTO.SectionEnum; + title: string; + instruction: string; + sortOrder?: number; +} +export namespace UpdateAiReviewGuidelineDTO { + export type SectionEnum = 'GENERAL' | 'TITLE_ENG' | 'TITLE_DE' | 'LEVEL_ENG' | 'LANGUAGE_ENG' | 'FREQUENCY_ENG' | 'CREDITS' | 'DURATION' | 'HOURS_LECTURE' | 'HOURS_EXERCISE' | 'HOURS_PRACTICAL' | 'HOURS_SEMINAR' | 'FIRST_SEMESTER_AVAILABLE' | 'SUCCESSOR_MODULE_NAME' | 'HOURS_TOTAL' | 'HOURS_SELF_STUDY' | 'HOURS_PRESENCE' | 'BULLET_POINTS' | 'EXAMINATION_ACHIEVEMENTS' | 'REPETITION' | 'RECOMMENDED_PREREQUISITES' | 'CONTENT' | 'LEARNING_OUTCOMES' | 'TEACHING_METHODS' | 'MEDIA' | 'LITERATURE' | 'RESPONSIBLES' | 'LV_SWS_LECTURER' | 'DEGREE_PROGRAM_ASSIGNMENTS'; + export const SectionEnum = { + General: 'GENERAL' as SectionEnum, + TitleEng: 'TITLE_ENG' as SectionEnum, + TitleDe: 'TITLE_DE' as SectionEnum, + LevelEng: 'LEVEL_ENG' as SectionEnum, + LanguageEng: 'LANGUAGE_ENG' as SectionEnum, + FrequencyEng: 'FREQUENCY_ENG' as SectionEnum, + Credits: 'CREDITS' as SectionEnum, + Duration: 'DURATION' as SectionEnum, + HoursLecture: 'HOURS_LECTURE' as SectionEnum, + HoursExercise: 'HOURS_EXERCISE' as SectionEnum, + HoursPractical: 'HOURS_PRACTICAL' as SectionEnum, + HoursSeminar: 'HOURS_SEMINAR' as SectionEnum, + FirstSemesterAvailable: 'FIRST_SEMESTER_AVAILABLE' as SectionEnum, + SuccessorModuleName: 'SUCCESSOR_MODULE_NAME' as SectionEnum, + HoursTotal: 'HOURS_TOTAL' as SectionEnum, + HoursSelfStudy: 'HOURS_SELF_STUDY' as SectionEnum, + HoursPresence: 'HOURS_PRESENCE' as SectionEnum, + BulletPoints: 'BULLET_POINTS' as SectionEnum, + ExaminationAchievements: 'EXAMINATION_ACHIEVEMENTS' as SectionEnum, + Repetition: 'REPETITION' as SectionEnum, + RecommendedPrerequisites: 'RECOMMENDED_PREREQUISITES' as SectionEnum, + Content: 'CONTENT' as SectionEnum, + LearningOutcomes: 'LEARNING_OUTCOMES' as SectionEnum, + TeachingMethods: 'TEACHING_METHODS' as SectionEnum, + Media: 'MEDIA' as SectionEnum, + Literature: 'LITERATURE' as SectionEnum, + Responsibles: 'RESPONSIBLES' as SectionEnum, + LvSwsLecturer: 'LV_SWS_LECTURER' as SectionEnum, + DegreeProgramAssignments: 'DEGREE_PROGRAM_ASSIGNMENTS' as SectionEnum + }; +} + + diff --git a/Client/src/app/core/modules/openapi/model/update-user-role-dto.ts b/Client/src/app/core/modules/openapi/model/update-user-role-dto.ts index 81cbb4d9..67f963a4 100644 --- a/Client/src/app/core/modules/openapi/model/update-user-role-dto.ts +++ b/Client/src/app/core/modules/openapi/model/update-user-role-dto.ts @@ -13,7 +13,7 @@ export interface UpdateUserRoleDTO { roles: Array; } export namespace UpdateUserRoleDTO { - export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; + export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR' | 'AI_REVIEW_GUIDELINE_MANAGER'; export const RolesEnum = { Admin: 'ADMIN' as RolesEnum, QualityManagement: 'QUALITY_MANAGEMENT' as RolesEnum, @@ -21,7 +21,8 @@ export namespace UpdateUserRoleDTO { ExaminationBoard: 'EXAMINATION_BOARD' as RolesEnum, Professor: 'PROFESSOR' as RolesEnum, ProgramCoordinator: 'PROGRAM_COORDINATOR' as RolesEnum, - SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RolesEnum + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RolesEnum, + AiReviewGuidelineManager: 'AI_REVIEW_GUIDELINE_MANAGER' as RolesEnum }; } diff --git a/Client/src/app/core/modules/openapi/model/user-dto.ts b/Client/src/app/core/modules/openapi/model/user-dto.ts index 04ae31aa..a31ec93f 100644 --- a/Client/src/app/core/modules/openapi/model/user-dto.ts +++ b/Client/src/app/core/modules/openapi/model/user-dto.ts @@ -18,7 +18,7 @@ export interface UserDTO { roles?: Array; } export namespace UserDTO { - export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; + export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR' | 'AI_REVIEW_GUIDELINE_MANAGER'; export const RolesEnum = { Admin: 'ADMIN' as RolesEnum, QualityManagement: 'QUALITY_MANAGEMENT' as RolesEnum, @@ -26,7 +26,8 @@ export namespace UserDTO { ExaminationBoard: 'EXAMINATION_BOARD' as RolesEnum, Professor: 'PROFESSOR' as RolesEnum, ProgramCoordinator: 'PROGRAM_COORDINATOR' as RolesEnum, - SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RolesEnum + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RolesEnum, + AiReviewGuidelineManager: 'AI_REVIEW_GUIDELINE_MANAGER' as RolesEnum }; } diff --git a/Client/src/app/core/modules/openapi/model/user.ts b/Client/src/app/core/modules/openapi/model/user.ts index 4dadbd6c..f53fd0c5 100644 --- a/Client/src/app/core/modules/openapi/model/user.ts +++ b/Client/src/app/core/modules/openapi/model/user.ts @@ -18,7 +18,7 @@ export interface User { roles?: Array; } export namespace User { - export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; + export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR' | 'AI_REVIEW_GUIDELINE_MANAGER'; export const RolesEnum = { Admin: 'ADMIN' as RolesEnum, QualityManagement: 'QUALITY_MANAGEMENT' as RolesEnum, @@ -26,7 +26,8 @@ export namespace User { ExaminationBoard: 'EXAMINATION_BOARD' as RolesEnum, Professor: 'PROFESSOR' as RolesEnum, ProgramCoordinator: 'PROGRAM_COORDINATOR' as RolesEnum, - SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RolesEnum + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RolesEnum, + AiReviewGuidelineManager: 'AI_REVIEW_GUIDELINE_MANAGER' as RolesEnum }; } diff --git a/Client/src/app/core/security/ai-review-guideline-manager.guard.ts b/Client/src/app/core/security/ai-review-guideline-manager.guard.ts new file mode 100644 index 00000000..e6c07eab --- /dev/null +++ b/Client/src/app/core/security/ai-review-guideline-manager.guard.ts @@ -0,0 +1,27 @@ +import { inject, Injectable, Injector } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { CanActivate, Router, UrlTree } from '@angular/router'; +import { filter, map, Observable, switchMap, take } from 'rxjs'; +import { isAiReviewGuidelineManagerRole } from '../shared/user-role.utils'; +import { SecurityStore } from './security-store.service'; + +@Injectable({ providedIn: 'root' }) +export class AiReviewGuidelineManagerGuard implements CanActivate { + private readonly injector = inject(Injector); + private readonly securityStore = inject(SecurityStore); + private readonly router = inject(Router); + + canActivate(): Observable { + return toObservable(this.securityStore.isLoading, { injector: this.injector }).pipe( + filter((loading) => !loading), + take(1), + switchMap(() => toObservable(this.securityStore.user, { injector: this.injector }).pipe(take(1))), + map((user) => { + if (isAiReviewGuidelineManagerRole(user?.roles)) { + return true; + } + return this.router.createUrlTree(['/']); + }) + ); + } +} diff --git a/Client/src/app/core/shared/proposal-review-section.utils.ts b/Client/src/app/core/shared/proposal-review-section.utils.ts new file mode 100644 index 00000000..de7e9276 --- /dev/null +++ b/Client/src/app/core/shared/proposal-review-section.utils.ts @@ -0,0 +1,38 @@ +import { AiReviewGuidelineDTO } from '../modules/openapi'; + +export const PROPOSAL_REVIEW_SECTION_OPTIONS: { label: string; value: AiReviewGuidelineDTO.SectionEnum }[] = [ + { label: 'General (whole proposal)', value: AiReviewGuidelineDTO.SectionEnum.General }, + { label: 'Title (English)', value: AiReviewGuidelineDTO.SectionEnum.TitleEng }, + { label: 'Title (German)', value: AiReviewGuidelineDTO.SectionEnum.TitleDe }, + { label: 'Level', value: AiReviewGuidelineDTO.SectionEnum.LevelEng }, + { label: 'Language', value: AiReviewGuidelineDTO.SectionEnum.LanguageEng }, + { label: 'Frequency', value: AiReviewGuidelineDTO.SectionEnum.FrequencyEng }, + { label: 'Credits', value: AiReviewGuidelineDTO.SectionEnum.Credits }, + { label: 'Duration', value: AiReviewGuidelineDTO.SectionEnum.Duration }, + { label: 'Hours (Lecture)', value: AiReviewGuidelineDTO.SectionEnum.HoursLecture }, + { label: 'Hours (Exercise)', value: AiReviewGuidelineDTO.SectionEnum.HoursExercise }, + { label: 'Hours (Practical)', value: AiReviewGuidelineDTO.SectionEnum.HoursPractical }, + { label: 'Hours (Seminar)', value: AiReviewGuidelineDTO.SectionEnum.HoursSeminar }, + { label: 'First semester available', value: AiReviewGuidelineDTO.SectionEnum.FirstSemesterAvailable }, + { label: 'Successor module', value: AiReviewGuidelineDTO.SectionEnum.SuccessorModuleName }, + { label: 'Total hours', value: AiReviewGuidelineDTO.SectionEnum.HoursTotal }, + { label: 'Self-study hours', value: AiReviewGuidelineDTO.SectionEnum.HoursSelfStudy }, + { label: 'Presence hours', value: AiReviewGuidelineDTO.SectionEnum.HoursPresence }, + { label: 'Key points', value: AiReviewGuidelineDTO.SectionEnum.BulletPoints }, + { label: 'Examination achievements', value: AiReviewGuidelineDTO.SectionEnum.ExaminationAchievements }, + { label: 'Repetition', value: AiReviewGuidelineDTO.SectionEnum.Repetition }, + { label: 'Recommended prerequisites', value: AiReviewGuidelineDTO.SectionEnum.RecommendedPrerequisites }, + { label: 'Module content', value: AiReviewGuidelineDTO.SectionEnum.Content }, + { label: 'Learning outcomes', value: AiReviewGuidelineDTO.SectionEnum.LearningOutcomes }, + { label: 'Teaching methods', value: AiReviewGuidelineDTO.SectionEnum.TeachingMethods }, + { label: 'Media', value: AiReviewGuidelineDTO.SectionEnum.Media }, + { label: 'Literature', value: AiReviewGuidelineDTO.SectionEnum.Literature }, + { label: 'Responsibles', value: AiReviewGuidelineDTO.SectionEnum.Responsibles }, + { label: 'Lecturer', value: AiReviewGuidelineDTO.SectionEnum.LvSwsLecturer }, + { label: 'Degree program assignments', value: AiReviewGuidelineDTO.SectionEnum.DegreeProgramAssignments } +]; + +export function getProposalReviewSectionLabel(section: AiReviewGuidelineDTO.SectionEnum | undefined): string { + if (!section) return ''; + return PROPOSAL_REVIEW_SECTION_OPTIONS.find((o) => o.value === section)?.label ?? section; +} diff --git a/Client/src/app/core/shared/user-role.utils.ts b/Client/src/app/core/shared/user-role.utils.ts index cd674760..c1127632 100644 --- a/Client/src/app/core/shared/user-role.utils.ts +++ b/Client/src/app/core/shared/user-role.utils.ts @@ -20,3 +20,7 @@ export function isProfessorRole(roles: User.RolesEnum[] | undefined | null): boo export function isReviewerRole(roles: User.RolesEnum[] | undefined | null): boolean { return Array.isArray(roles) && roles.some((r) => (REVIEWER_ROLES as readonly string[]).includes(r)); } + +export function isAiReviewGuidelineManagerRole(roles: User.RolesEnum[] | undefined | null): boolean { + return Array.isArray(roles) && roles.includes(User.RolesEnum.AiReviewGuidelineManager); +} diff --git a/Client/src/app/pages/ai-review-guidelines/ai-review-guidelines-page.component.html b/Client/src/app/pages/ai-review-guidelines/ai-review-guidelines-page.component.html new file mode 100644 index 00000000..e7310406 --- /dev/null +++ b/Client/src/app/pages/ai-review-guidelines/ai-review-guidelines-page.component.html @@ -0,0 +1,96 @@ +

AI review guidelines

+

+ Maintain the shared rules used when the system generates automated LLM reviews of module proposals. Guidelines are scoped to + proposal sections so the model can apply only relevant criteria per field. +

+ +
+

All guidelines

+ +
+ +@if (!loading()) { + + + + Section + Title + Order + Last updated by + Actions + + + + + {{ getSectionLabel(g.section) }} + + {{ g.title }} +

{{ g.instruction }}

+ + {{ g.sortOrder }} + + {{ g.updatedByDisplayName || g.createdByDisplayName || '—' }} + + + + + + +
+ + + No guidelines yet. Add rules for reviewers and the LLM to follow. + + +
+} @else { +

Loading…

+} + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +

Lower numbers appear first within the same section.

+
+
+ + + + +
diff --git a/Client/src/app/pages/ai-review-guidelines/ai-review-guidelines-page.component.ts b/Client/src/app/pages/ai-review-guidelines/ai-review-guidelines-page.component.ts new file mode 100644 index 00000000..eafafc30 --- /dev/null +++ b/Client/src/app/pages/ai-review-guidelines/ai-review-guidelines-page.component.ts @@ -0,0 +1,160 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ButtonModule } from 'primeng/button'; +import { DialogModule } from 'primeng/dialog'; +import { SelectModule } from 'primeng/select'; +import { InputNumberModule } from 'primeng/inputnumber'; +import { InputTextModule } from 'primeng/inputtext'; +import { TableModule } from 'primeng/table'; +import { TextareaModule } from 'primeng/textarea'; +import { ToastModule } from 'primeng/toast'; +import { TooltipModule } from 'primeng/tooltip'; +import { MessageService } from 'primeng/api'; +import { firstValueFrom } from 'rxjs'; +import { + AiReviewGuidelineControllerService, + AiReviewGuidelineDTO, + CreateAiReviewGuidelineDTO, + UpdateAiReviewGuidelineDTO +} from '../../core/modules/openapi'; +import { + getProposalReviewSectionLabel, + PROPOSAL_REVIEW_SECTION_OPTIONS +} from '../../core/shared/proposal-review-section.utils'; + +@Component({ + selector: 'app-ai-review-guidelines-page', + standalone: true, + imports: [ + FormsModule, + TableModule, + ButtonModule, + InputTextModule, + TextareaModule, + InputNumberModule, + SelectModule, + DialogModule, + ToastModule, + TooltipModule + ], + templateUrl: './ai-review-guidelines-page.component.html' +}) +export class AiReviewGuidelinesPageComponent { + private readonly guidelinesService = inject(AiReviewGuidelineControllerService); + private readonly messageService = inject(MessageService); + + guidelines = signal([]); + loading = signal(true); + saving = signal(false); + + sectionOptions = PROPOSAL_REVIEW_SECTION_OPTIONS; + getSectionLabel = getProposalReviewSectionLabel; + + dialogVisible = signal(false); + editingGuidelineId = signal(null); + + formSection: AiReviewGuidelineDTO.SectionEnum = AiReviewGuidelineDTO.SectionEnum.General; + formTitle = ''; + formInstruction = ''; + formSortOrder = 0; + + dialogTitle = computed(() => (this.editingGuidelineId() != null ? 'Edit guideline' : 'Create guideline')); + + constructor() { + this.loadGuidelines(); + } + + async loadGuidelines() { + this.loading.set(true); + try { + const list = await firstValueFrom(this.guidelinesService.getAllGuidelines()); + this.guidelines.set(list ?? []); + } catch { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to load AI review guidelines.' }); + this.guidelines.set([]); + } finally { + this.loading.set(false); + } + } + + openCreateDialog() { + this.editingGuidelineId.set(null); + this.formSection = AiReviewGuidelineDTO.SectionEnum.General; + this.formTitle = ''; + this.formInstruction = ''; + this.formSortOrder = 0; + this.dialogVisible.set(true); + } + + openEditDialog(guideline: AiReviewGuidelineDTO) { + if (guideline.guidelineId == null) return; + this.editingGuidelineId.set(guideline.guidelineId); + this.formSection = guideline.section ?? AiReviewGuidelineDTO.SectionEnum.General; + this.formTitle = guideline.title ?? ''; + this.formInstruction = guideline.instruction ?? ''; + this.formSortOrder = guideline.sortOrder ?? 0; + this.dialogVisible.set(true); + } + + onDialogVisibleChange(visible: boolean) { + this.dialogVisible.set(visible); + if (!visible) { + this.editingGuidelineId.set(null); + } + } + + closeDialog() { + this.onDialogVisibleChange(false); + } + + async saveGuideline() { + const title = this.formTitle.trim(); + const instruction = this.formInstruction.trim(); + if (!title) { + this.messageService.add({ severity: 'warn', summary: 'Validation', detail: 'Enter a title.' }); + return; + } + if (!instruction) { + this.messageService.add({ severity: 'warn', summary: 'Validation', detail: 'Enter guideline instructions.' }); + return; + } + + const payload: CreateAiReviewGuidelineDTO = { + section: this.formSection, + title, + instruction, + sortOrder: this.formSortOrder + }; + + this.saving.set(true); + try { + const id = this.editingGuidelineId(); + if (id != null) { + await firstValueFrom(this.guidelinesService.updateGuideline(id, payload as UpdateAiReviewGuidelineDTO)); + this.messageService.add({ severity: 'success', summary: 'Saved', detail: 'Guideline updated.' }); + } else { + await firstValueFrom(this.guidelinesService.createGuideline(payload)); + this.messageService.add({ severity: 'success', summary: 'Created', detail: 'Guideline created.' }); + } + this.closeDialog(); + await this.loadGuidelines(); + } catch { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to save guideline.' }); + } finally { + this.saving.set(false); + } + } + + async deleteGuideline(guideline: AiReviewGuidelineDTO) { + const id = guideline.guidelineId; + if (id == null) return; + if (!confirm(`Delete guideline "${guideline.title}"?`)) return; + try { + await firstValueFrom(this.guidelinesService.deleteGuideline(id)); + this.messageService.add({ severity: 'success', summary: 'Deleted', detail: 'Guideline deleted.' }); + await this.loadGuidelines(); + } catch { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to delete guideline.' }); + } + } +} diff --git a/Client/src/app/pages/index/index.component.html b/Client/src/app/pages/index/index.component.html index f485b929..4dd346f6 100644 --- a/Client/src/app/pages/index/index.component.html +++ b/Client/src/app/pages/index/index.component.html @@ -54,6 +54,12 @@

Welcome, {{ user()?.firstName }} {{ user } + @if (isAiReviewGuidelineManager()) { + +

Maintain shared rules for automated LLM proposal reviews.

+ +
+ } @if (isReviewer()) {

Review and submit feedback on proposals.

diff --git a/Client/src/app/pages/index/index.component.ts b/Client/src/app/pages/index/index.component.ts index 63ac1578..ab732d68 100644 --- a/Client/src/app/pages/index/index.component.ts +++ b/Client/src/app/pages/index/index.component.ts @@ -5,7 +5,7 @@ import { DividerModule } from 'primeng/divider'; import { PanelModule } from 'primeng/panel'; import { SecurityStore } from '../../core/security/security-store.service'; import { SignInComponent } from '../../components/sign-in/sign-in.component'; -import { isAdminRole, isProfessorRole, isReviewerRole } from '../../core/shared/user-role.utils'; +import { isAdminRole, isAiReviewGuidelineManagerRole, isProfessorRole, isReviewerRole } from '../../core/shared/user-role.utils'; @Component({ selector: 'index-component', @@ -20,4 +20,5 @@ export class IndexComponent { isAdmin = (): boolean => isAdminRole(this.user()?.roles); isProfessor = (): boolean => isProfessorRole(this.user()?.roles); isReviewer = (): boolean => isReviewerRole(this.user()?.roles); + isAiReviewGuidelineManager = (): boolean => isAiReviewGuidelineManagerRole(this.user()?.roles); } diff --git a/README.md b/README.md index d8cd084a..c73eba35 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,21 @@ Make sure you have the following installed: - Docker and Docker Compose - Node.js v20.19+ and npm - Angular CLI -- Java JDK 21 +- Java JDK 21 (the server build uses Gradle’s Java 21 toolchain; JDK 25 alone is not enough) + +#### Java 21 on macOS (Homebrew) + +If `./gradlew bootRun` fails with “Cannot find a Java installation … matching languageVersion=21”, install JDK 21: + +```bash +brew install openjdk@21 +``` + +The `Server/gradle.properties` file registers the Homebrew JDK 21 path for Gradle. If you installed JDK 21 elsewhere, update `org.gradle.java.installations.paths` in that file, or run: + +```bash +export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home" +``` ### Environment Configuration @@ -96,7 +110,7 @@ Ports are configured in your `.env` file. #### 2. Start the Spring Boot Server -From the `Server` directory: +From the repository root, start the server from the `Server` directory (if your shell is already in `Server`, skip `cd Server`): ```bash cd Server diff --git a/Server/gradle.properties b/Server/gradle.properties new file mode 100644 index 00000000..6c7536d3 --- /dev/null +++ b/Server/gradle.properties @@ -0,0 +1,3 @@ +# Gradle Java toolchain: register Homebrew JDK 21 (install with: brew install openjdk@21) +# Remove or adjust this path on Linux/Windows if you use a different JDK 21 location. +org.gradle.java.installations.paths=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/AiReviewGuidelineController.java b/Server/src/main/java/modulemanagement/ls1/controllers/AiReviewGuidelineController.java new file mode 100644 index 00000000..7b984a2f --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/controllers/AiReviewGuidelineController.java @@ -0,0 +1,57 @@ +package modulemanagement.ls1.controllers; + +import jakarta.validation.Valid; +import modulemanagement.ls1.dtos.AiReviewGuidelineDTO; +import modulemanagement.ls1.dtos.CreateAiReviewGuidelineDTO; +import modulemanagement.ls1.dtos.UpdateAiReviewGuidelineDTO; +import modulemanagement.ls1.models.User; +import modulemanagement.ls1.services.AiReviewGuidelineService; +import modulemanagement.ls1.shared.CurrentUser; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/ai-review-guidelines") +@PreAuthorize("hasRole('AI_REVIEW_GUIDELINE_MANAGER')") +public class AiReviewGuidelineController { + + private final AiReviewGuidelineService aiReviewGuidelineService; + + public AiReviewGuidelineController(AiReviewGuidelineService aiReviewGuidelineService) { + this.aiReviewGuidelineService = aiReviewGuidelineService; + } + + @GetMapping + public ResponseEntity> getAllGuidelines() { + return ResponseEntity.ok(aiReviewGuidelineService.getAllGuidelines()); + } + + @GetMapping("/{id}") + public ResponseEntity getGuideline(@PathVariable Long id) { + return ResponseEntity.ok(aiReviewGuidelineService.getGuideline(id)); + } + + @PostMapping + public ResponseEntity createGuideline( + @CurrentUser User user, + @Valid @RequestBody CreateAiReviewGuidelineDTO dto) { + return ResponseEntity.ok(aiReviewGuidelineService.createGuideline(user, dto)); + } + + @PutMapping("/{id}") + public ResponseEntity updateGuideline( + @CurrentUser User user, + @PathVariable Long id, + @Valid @RequestBody UpdateAiReviewGuidelineDTO dto) { + return ResponseEntity.ok(aiReviewGuidelineService.updateGuideline(id, user, dto)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteGuideline(@PathVariable Long id) { + aiReviewGuidelineService.deleteGuideline(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/AiReviewGuidelineDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/AiReviewGuidelineDTO.java new file mode 100644 index 00000000..9c6e3eee --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/AiReviewGuidelineDTO.java @@ -0,0 +1,55 @@ +package modulemanagement.ls1.dtos; + +import lombok.Data; +import modulemanagement.ls1.enums.ProposalReviewSection; +import modulemanagement.ls1.models.AiReviewGuideline; + +import java.time.LocalDateTime; + +@Data +public class AiReviewGuidelineDTO { + private Long guidelineId; + private ProposalReviewSection section; + private String title; + private String instruction; + private int sortOrder; + private String createdByUserId; + private String createdByDisplayName; + private String updatedByUserId; + private String updatedByDisplayName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static AiReviewGuidelineDTO fromEntity(AiReviewGuideline entity) { + if (entity == null) { + return null; + } + AiReviewGuidelineDTO dto = new AiReviewGuidelineDTO(); + dto.setGuidelineId(entity.getGuidelineId()); + dto.setSection(entity.getSection()); + dto.setTitle(entity.getTitle()); + dto.setInstruction(entity.getInstruction()); + dto.setSortOrder(entity.getSortOrder()); + if (entity.getCreatedBy() != null) { + dto.setCreatedByUserId(entity.getCreatedBy().getUserId().toString()); + dto.setCreatedByDisplayName(formatDisplayName(entity.getCreatedBy())); + } + if (entity.getUpdatedBy() != null) { + dto.setUpdatedByUserId(entity.getUpdatedBy().getUserId().toString()); + dto.setUpdatedByDisplayName(formatDisplayName(entity.getUpdatedBy())); + } + dto.setCreatedAt(entity.getCreatedAt()); + dto.setUpdatedAt(entity.getUpdatedAt()); + return dto; + } + + private static String formatDisplayName(modulemanagement.ls1.models.User user) { + String first = user.getFirstName() != null ? user.getFirstName().trim() : ""; + String last = user.getLastName() != null ? user.getLastName().trim() : ""; + String combined = (first + " " + last).trim(); + if (!combined.isEmpty()) { + return combined; + } + return user.getUserName() != null ? user.getUserName() : user.getUserId().toString(); + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/CreateAiReviewGuidelineDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/CreateAiReviewGuidelineDTO.java new file mode 100644 index 00000000..f04b3231 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/CreateAiReviewGuidelineDTO.java @@ -0,0 +1,22 @@ +package modulemanagement.ls1.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import modulemanagement.ls1.enums.ProposalReviewSection; + +@Data +public class CreateAiReviewGuidelineDTO { + @NotNull + private ProposalReviewSection section; + + @NotBlank + @Size(max = 256) + private String title; + + @NotBlank + private String instruction; + + private Integer sortOrder; +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/UpdateAiReviewGuidelineDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateAiReviewGuidelineDTO.java new file mode 100644 index 00000000..3c57dd07 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateAiReviewGuidelineDTO.java @@ -0,0 +1,22 @@ +package modulemanagement.ls1.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import modulemanagement.ls1.enums.ProposalReviewSection; + +@Data +public class UpdateAiReviewGuidelineDTO { + @NotNull + private ProposalReviewSection section; + + @NotBlank + @Size(max = 256) + private String title; + + @NotBlank + private String instruction; + + private Integer sortOrder; +} diff --git a/Server/src/main/java/modulemanagement/ls1/enums/ProposalReviewSection.java b/Server/src/main/java/modulemanagement/ls1/enums/ProposalReviewSection.java new file mode 100644 index 00000000..27c233b5 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/enums/ProposalReviewSection.java @@ -0,0 +1,37 @@ +package modulemanagement.ls1.enums; + +/** + * Proposal sections that AI review guidelines can target. Aligns with {@code ModuleVersion} fields + * and reviewer feedback dimensions so the LLM can load only relevant rules per field. + */ +public enum ProposalReviewSection { + GENERAL, + TITLE_ENG, + TITLE_DE, + LEVEL_ENG, + LANGUAGE_ENG, + FREQUENCY_ENG, + CREDITS, + DURATION, + HOURS_LECTURE, + HOURS_EXERCISE, + HOURS_PRACTICAL, + HOURS_SEMINAR, + FIRST_SEMESTER_AVAILABLE, + SUCCESSOR_MODULE_NAME, + HOURS_TOTAL, + HOURS_SELF_STUDY, + HOURS_PRESENCE, + BULLET_POINTS, + EXAMINATION_ACHIEVEMENTS, + REPETITION, + RECOMMENDED_PREREQUISITES, + CONTENT, + LEARNING_OUTCOMES, + TEACHING_METHODS, + MEDIA, + LITERATURE, + RESPONSIBLES, + LV_SWS_LECTURER, + DEGREE_PROGRAM_ASSIGNMENTS +} diff --git a/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java b/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java index b80b267c..0b7626cf 100644 --- a/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java +++ b/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java @@ -9,5 +9,7 @@ public enum UserRole { /** User is responsible for at least one degree program; can receive and respond to feedback requests for those. */ PROGRAM_COORDINATOR, /** User is coordinator for at least one area of specialization; can receive and respond to feedback requests for those. */ - SPECIALIZATION_AREA_COORDINATOR + SPECIALIZATION_AREA_COORDINATOR, + /** User can maintain shared AI review guidelines applied when generating automated proposal reviews. */ + AI_REVIEW_GUIDELINE_MANAGER } diff --git a/Server/src/main/java/modulemanagement/ls1/models/AiReviewGuideline.java b/Server/src/main/java/modulemanagement/ls1/models/AiReviewGuideline.java new file mode 100644 index 00000000..31b3356a --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/models/AiReviewGuideline.java @@ -0,0 +1,49 @@ +package modulemanagement.ls1.models; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.NoArgsConstructor; +import modulemanagement.ls1.enums.ProposalReviewSection; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@Entity +@Table(name = "ai_review_guideline") +public class AiReviewGuideline { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "guideline_id") + private Long guidelineId; + + @Enumerated(EnumType.STRING) + @Column(name = "section", nullable = false) + @NotNull + private ProposalReviewSection section; + + @Column(name = "title", nullable = false, length = 256) + private String title; + + @Column(name = "instruction", nullable = false, columnDefinition = "CLOB") + private String instruction; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "created_by", nullable = false) + private User createdBy; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "updated_by") + private User updatedBy; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/AiReviewGuidelineRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/AiReviewGuidelineRepository.java new file mode 100644 index 00000000..88d1d828 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/repositories/AiReviewGuidelineRepository.java @@ -0,0 +1,14 @@ +package modulemanagement.ls1.repositories; + +import modulemanagement.ls1.enums.ProposalReviewSection; +import modulemanagement.ls1.models.AiReviewGuideline; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface AiReviewGuidelineRepository extends JpaRepository { + + List findAllByOrderBySectionAscSortOrderAscGuidelineIdAsc(); + + List findBySectionOrderBySortOrderAscGuidelineIdAsc(ProposalReviewSection section); +} diff --git a/Server/src/main/java/modulemanagement/ls1/services/AiReviewGuidelineService.java b/Server/src/main/java/modulemanagement/ls1/services/AiReviewGuidelineService.java new file mode 100644 index 00000000..4140dc89 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/services/AiReviewGuidelineService.java @@ -0,0 +1,74 @@ +package modulemanagement.ls1.services; + +import modulemanagement.ls1.dtos.AiReviewGuidelineDTO; +import modulemanagement.ls1.dtos.CreateAiReviewGuidelineDTO; +import modulemanagement.ls1.dtos.UpdateAiReviewGuidelineDTO; +import modulemanagement.ls1.models.AiReviewGuideline; +import modulemanagement.ls1.models.User; +import modulemanagement.ls1.repositories.AiReviewGuidelineRepository; +import modulemanagement.ls1.shared.ResourceNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class AiReviewGuidelineService { + + private final AiReviewGuidelineRepository aiReviewGuidelineRepository; + + public AiReviewGuidelineService(AiReviewGuidelineRepository aiReviewGuidelineRepository) { + this.aiReviewGuidelineRepository = aiReviewGuidelineRepository; + } + + public List getAllGuidelines() { + return aiReviewGuidelineRepository.findAllByOrderBySectionAscSortOrderAscGuidelineIdAsc().stream() + .map(AiReviewGuidelineDTO::fromEntity) + .collect(Collectors.toList()); + } + + public AiReviewGuidelineDTO getGuideline(Long id) { + AiReviewGuideline guideline = aiReviewGuidelineRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("AI review guideline not found: " + id)); + return AiReviewGuidelineDTO.fromEntity(guideline); + } + + @Transactional + public AiReviewGuidelineDTO createGuideline(User actor, CreateAiReviewGuidelineDTO dto) { + LocalDateTime now = LocalDateTime.now(); + AiReviewGuideline guideline = new AiReviewGuideline(); + guideline.setSection(dto.getSection()); + guideline.setTitle(dto.getTitle().trim()); + guideline.setInstruction(dto.getInstruction().trim()); + guideline.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : 0); + guideline.setCreatedBy(actor); + guideline.setUpdatedBy(actor); + guideline.setCreatedAt(now); + guideline.setUpdatedAt(now); + guideline = aiReviewGuidelineRepository.save(guideline); + return AiReviewGuidelineDTO.fromEntity(guideline); + } + + @Transactional + public AiReviewGuidelineDTO updateGuideline(Long id, User actor, UpdateAiReviewGuidelineDTO dto) { + AiReviewGuideline guideline = aiReviewGuidelineRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("AI review guideline not found: " + id)); + guideline.setSection(dto.getSection()); + guideline.setTitle(dto.getTitle().trim()); + guideline.setInstruction(dto.getInstruction().trim()); + guideline.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : guideline.getSortOrder()); + guideline.setUpdatedBy(actor); + guideline.setUpdatedAt(LocalDateTime.now()); + guideline = aiReviewGuidelineRepository.save(guideline); + return AiReviewGuidelineDTO.fromEntity(guideline); + } + + @Transactional + public void deleteGuideline(Long id) { + AiReviewGuideline guideline = aiReviewGuidelineRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("AI review guideline not found: " + id)); + aiReviewGuidelineRepository.delete(guideline); + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtil.java b/Server/src/main/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtil.java new file mode 100644 index 00000000..f9c99340 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtil.java @@ -0,0 +1,159 @@ +package modulemanagement.ls1.shared; + +import modulemanagement.ls1.enums.ProposalReviewSection; +import modulemanagement.ls1.models.AiReviewGuideline; +import modulemanagement.ls1.models.ModuleVersion; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Maps proposal sections to module fields and formats stored guidelines for LLM review prompts. + */ +public final class AiReviewGuidelinePromptUtil { + + private static final Map SECTION_LABELS = Map.ofEntries( + Map.entry(ProposalReviewSection.GENERAL, "General (whole proposal)"), + Map.entry(ProposalReviewSection.TITLE_ENG, "Title (English)"), + Map.entry(ProposalReviewSection.TITLE_DE, "Title (German)"), + Map.entry(ProposalReviewSection.LEVEL_ENG, "Level"), + Map.entry(ProposalReviewSection.LANGUAGE_ENG, "Language"), + Map.entry(ProposalReviewSection.FREQUENCY_ENG, "Frequency"), + Map.entry(ProposalReviewSection.CREDITS, "Credits"), + Map.entry(ProposalReviewSection.DURATION, "Duration"), + Map.entry(ProposalReviewSection.HOURS_LECTURE, "Hours (Lecture)"), + Map.entry(ProposalReviewSection.HOURS_EXERCISE, "Hours (Exercise)"), + Map.entry(ProposalReviewSection.HOURS_PRACTICAL, "Hours (Practical)"), + Map.entry(ProposalReviewSection.HOURS_SEMINAR, "Hours (Seminar)"), + Map.entry(ProposalReviewSection.FIRST_SEMESTER_AVAILABLE, "First semester available"), + Map.entry(ProposalReviewSection.SUCCESSOR_MODULE_NAME, "Successor module"), + Map.entry(ProposalReviewSection.HOURS_TOTAL, "Total hours"), + Map.entry(ProposalReviewSection.HOURS_SELF_STUDY, "Self-study hours"), + Map.entry(ProposalReviewSection.HOURS_PRESENCE, "Presence hours"), + Map.entry(ProposalReviewSection.BULLET_POINTS, "Key points"), + Map.entry(ProposalReviewSection.EXAMINATION_ACHIEVEMENTS, "Examination achievements"), + Map.entry(ProposalReviewSection.REPETITION, "Repetition"), + Map.entry(ProposalReviewSection.RECOMMENDED_PREREQUISITES, "Recommended prerequisites"), + Map.entry(ProposalReviewSection.CONTENT, "Module content"), + Map.entry(ProposalReviewSection.LEARNING_OUTCOMES, "Learning outcomes"), + Map.entry(ProposalReviewSection.TEACHING_METHODS, "Teaching methods"), + Map.entry(ProposalReviewSection.MEDIA, "Media"), + Map.entry(ProposalReviewSection.LITERATURE, "Literature"), + Map.entry(ProposalReviewSection.RESPONSIBLES, "Responsibles"), + Map.entry(ProposalReviewSection.LV_SWS_LECTURER, "Lecturer"), + Map.entry(ProposalReviewSection.DEGREE_PROGRAM_ASSIGNMENTS, "Degree program assignments")); + + private static final Map> SECTION_VALUE_EXTRACTORS = + new EnumMap<>(ProposalReviewSection.class); + + static { + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.TITLE_ENG, ModuleVersion::getTitleEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.TITLE_DE, ModuleVersion::getTitleDe); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.LEVEL_ENG, ModuleVersion::getLevelEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.LANGUAGE_ENG, + mv -> mv.getLanguageEng() != null ? mv.getLanguageEng().name() : null); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.FREQUENCY_ENG, ModuleVersion::getFrequencyEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.CREDITS, mv -> integerToString(mv.getCredits())); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.DURATION, ModuleVersion::getDuration); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_LECTURE, mv -> integerToString(mv.getHoursLecture())); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_EXERCISE, mv -> integerToString(mv.getHoursExercise())); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_PRACTICAL, mv -> integerToString(mv.getHoursPractical())); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_SEMINAR, mv -> integerToString(mv.getHoursSeminar())); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.FIRST_SEMESTER_AVAILABLE, ModuleVersion::getFirstSemesterAvailable); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.SUCCESSOR_MODULE_NAME, ModuleVersion::getSuccessorModuleName); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_TOTAL, mv -> integerToString(mv.getHoursTotal())); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_SELF_STUDY, mv -> integerToString(mv.getHoursSelfStudy())); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_PRESENCE, mv -> integerToString(mv.getHoursPresence())); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.BULLET_POINTS, ModuleVersion::getBulletPoints); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.EXAMINATION_ACHIEVEMENTS, ModuleVersion::getExaminationAchievementsEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.REPETITION, ModuleVersion::getRepetitionEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.RECOMMENDED_PREREQUISITES, ModuleVersion::getRecommendedPrerequisitesEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.CONTENT, ModuleVersion::getContentEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.LEARNING_OUTCOMES, ModuleVersion::getLearningOutcomesEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.TEACHING_METHODS, ModuleVersion::getTeachingMethodsEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.MEDIA, ModuleVersion::getMediaEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.LITERATURE, ModuleVersion::getLiteratureEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.RESPONSIBLES, ModuleVersion::getResponsiblesEng); + SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.LV_SWS_LECTURER, ModuleVersion::getLvSwsLecturerEng); + } + + private AiReviewGuidelinePromptUtil() { + } + + public static String getSectionLabel(ProposalReviewSection section) { + return SECTION_LABELS.getOrDefault(section, section.name()); + } + + public static String extractFieldValue(ModuleVersion moduleVersion, ProposalReviewSection section) { + if (moduleVersion == null || section == null) { + return null; + } + Function extractor = SECTION_VALUE_EXTRACTORS.get(section); + if (extractor == null) { + return null; + } + String value = extractor.apply(moduleVersion); + return value != null && !value.isBlank() ? value : null; + } + + public static Map> groupBySection(List guidelines) { + if (guidelines == null || guidelines.isEmpty()) { + return Map.of(); + } + return guidelines.stream().collect(Collectors.groupingBy(AiReviewGuideline::getSection, Collectors.toList())); + } + + /** + * Formats guidelines for a single section (including {@link ProposalReviewSection#GENERAL} when passed). + */ + public static String formatGuidelinesForSection(List guidelines, ProposalReviewSection section) { + if (guidelines == null || guidelines.isEmpty() || section == null) { + return ""; + } + List matching = guidelines.stream() + .filter(g -> g.getSection() == section) + .toList(); + if (matching.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + sb.append("Review guidelines for ").append(getSectionLabel(section)).append(":\n"); + int index = 1; + for (AiReviewGuideline guideline : matching) { + sb.append(index++).append(". ").append(guideline.getTitle()).append("\n"); + sb.append(guideline.getInstruction().trim()).append("\n\n"); + } + return sb.toString().trim(); + } + + /** + * Builds a section-scoped review prompt block: field value plus GENERAL and section-specific guidelines. + */ + public static String buildSectionReviewContext(ModuleVersion moduleVersion, ProposalReviewSection section, + List allGuidelines) { + List parts = new ArrayList<>(); + String fieldValue = extractFieldValue(moduleVersion, section); + if (fieldValue != null) { + parts.add(getSectionLabel(section) + " (submitted value):\n" + fieldValue); + } + String generalRules = formatGuidelinesForSection(allGuidelines, ProposalReviewSection.GENERAL); + if (!generalRules.isEmpty()) { + parts.add(generalRules); + } + if (section != ProposalReviewSection.GENERAL) { + String sectionRules = formatGuidelinesForSection(allGuidelines, section); + if (!sectionRules.isEmpty()) { + parts.add(sectionRules); + } + } + return String.join("\n\n", parts); + } + + private static String integerToString(Integer value) { + return value != null ? value.toString() : null; + } +} diff --git a/Server/src/main/resources/db/changelog/changes/0017_ai_review_guidelines.yaml b/Server/src/main/resources/db/changelog/changes/0017_ai_review_guidelines.yaml new file mode 100644 index 00000000..67d94270 --- /dev/null +++ b/Server/src/main/resources/db/changelog/changes/0017_ai_review_guidelines.yaml @@ -0,0 +1,75 @@ +databaseChangeLog: + - changeSet: + id: 0017a_ai_review_guideline_table + author: module-management + changes: + - createTable: + tableName: ai_review_guideline + columns: + - column: + name: guideline_id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + - column: + name: section + type: VARCHAR(64) + constraints: + nullable: false + - column: + name: title + type: VARCHAR(256) + constraints: + nullable: false + - column: + name: instruction + type: CLOB + constraints: + nullable: false + - column: + name: sort_order + type: INT + defaultValueNumeric: 0 + constraints: + nullable: false + - column: + name: created_by + type: UUID + constraints: + nullable: false + - column: + name: updated_by + type: UUID + constraints: + nullable: true + - column: + name: created_at + type: TIMESTAMP + constraints: + nullable: false + - column: + name: updated_at + type: TIMESTAMP + constraints: + nullable: false + - addForeignKeyConstraint: + baseTableName: ai_review_guideline + baseColumnNames: created_by + referencedTableName: app_user + referencedColumnNames: user_id + constraintName: fk_ai_review_guideline_created_by + - addForeignKeyConstraint: + baseTableName: ai_review_guideline + baseColumnNames: updated_by + referencedTableName: app_user + referencedColumnNames: user_id + constraintName: fk_ai_review_guideline_updated_by + - createIndex: + tableName: ai_review_guideline + indexName: idx_ai_review_guideline_section_sort + columns: + - column: + name: section + - column: + name: sort_order diff --git a/Server/src/main/resources/db/changelog/master.yaml b/Server/src/main/resources/db/changelog/master.yaml index ee7d4285..38b6dbb3 100644 --- a/Server/src/main/resources/db/changelog/master.yaml +++ b/Server/src/main/resources/db/changelog/master.yaml @@ -47,3 +47,6 @@ databaseChangeLog: - include: relativeToChangelogFile: true file: changes/0016_feedback_assigned_reviewer.yaml + - include: + relativeToChangelogFile: true + file: changes/0017_ai_review_guidelines.yaml From b9a04746eb7cc215b22e8857100129cb535e1611 Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Mon, 15 Jun 2026 19:12:31 +0200 Subject: [PATCH 2/2] generate prompt and parse response --- Client/src/app/app.routes.ts | 7 +- .../breadcrumb/breadcrumb.component.ts | 22 ++- .../create-edit-base.component.html | 18 ++ .../modules/openapi/.openapi-generator/FILES | 2 + .../api/module-version-controller.service.ts | 73 ++++++++ ...ule-version-controller.serviceInterface.ts | 9 + .../core/modules/openapi/model/feedback.ts | 2 +- .../app/core/modules/openapi/model/models.ts | 2 + .../openapi/model/proposal-ai-review-dto.ts | 20 +++ .../model/proposal-ai-review-section-dto.ts | 60 +++++++ .../feedback-view.component.html | 3 +- .../module-version-view.component.html | 3 +- .../proposal-ai-review-page.component.html | 97 ++++++++++ .../proposal-ai-review-page.component.ts | 150 ++++++++++++++++ .../proposal-view.component.html | 16 ++ README.md | 11 ++ .../controllers/ModuleVersionController.java | 19 +- .../ls1/dtos/ProposalAiReviewDTO.java | 17 ++ .../ls1/dtos/ProposalAiReviewSectionDTO.java | 14 ++ .../ls1/enums/AiReviewSeverity.java | 7 + .../ls1/models/ProposalAiReview.java | 47 +++++ .../ls1/models/ProposalAiReviewSection.java | 43 +++++ .../ls1/repositories/FeedbackRepository.java | 3 + .../ProposalAiReviewRepository.java | 11 ++ .../ls1/services/ProposalAiReviewService.java | 166 ++++++++++++++++++ .../ls1/shared/ProposalAiReviewParser.java | 130 ++++++++++++++ .../shared/ProposalAiReviewPromptUtil.java | 117 ++++++++++++ Server/src/main/resources/application.yaml | 5 + .../changes/0018_proposal_ai_review.yaml | 104 +++++++++++ .../main/resources/db/changelog/master.yaml | 3 + .../AiReviewGuidelinePromptUtilTest.java | 81 +++++++++ .../shared/ProposalAiReviewParserTest.java | 150 ++++++++++++++++ .../ProposalAiReviewPromptUtilTest.java | 103 +++++++++++ 33 files changed, 1508 insertions(+), 7 deletions(-) create mode 100644 Client/src/app/core/modules/openapi/model/proposal-ai-review-dto.ts create mode 100644 Client/src/app/core/modules/openapi/model/proposal-ai-review-section-dto.ts create mode 100644 Client/src/app/pages/proposal-ai-review/proposal-ai-review-page.component.html create mode 100644 Client/src/app/pages/proposal-ai-review/proposal-ai-review-page.component.ts create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewSectionDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/enums/AiReviewSeverity.java create mode 100644 Server/src/main/java/modulemanagement/ls1/models/ProposalAiReview.java create mode 100644 Server/src/main/java/modulemanagement/ls1/models/ProposalAiReviewSection.java create mode 100644 Server/src/main/java/modulemanagement/ls1/repositories/ProposalAiReviewRepository.java create mode 100644 Server/src/main/java/modulemanagement/ls1/services/ProposalAiReviewService.java create mode 100644 Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewParser.java create mode 100644 Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtil.java create mode 100644 Server/src/main/resources/db/changelog/changes/0018_proposal_ai_review.yaml create mode 100644 Server/src/test/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtilTest.java create mode 100644 Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewParserTest.java create mode 100644 Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtilTest.java diff --git a/Client/src/app/app.routes.ts b/Client/src/app/app.routes.ts index 4ba329d5..41fe3a2a 100644 --- a/Client/src/app/app.routes.ts +++ b/Client/src/app/app.routes.ts @@ -10,6 +10,7 @@ import { AuthGuard } from './core/security/auth.guard'; import { AdminGuard } from './core/security/admin.guard'; import { ModuleVersionViewComponent } from './pages/module-version-view/module-version-view.component'; import { SimilarModulesPage } from './pages/similar-modules/similar-modules.component'; +import { ProposalAiReviewPageComponent } from './pages/proposal-ai-review/proposal-ai-review-page.component'; import { AccountLayoutComponent } from './pages/account-management/account-layout/account-layout.component'; import { AccountInformationComponent } from './pages/account-management/account-information/account-information.component'; import { AccountPasskeysComponent } from './pages/account-management/passkeys/account-passkeys.component'; @@ -32,7 +33,8 @@ export const routes: Routes = [ { path: ':id', component: ProposalViewComponent }, { path: ':id/version/:versionId', component: ModuleVersionViewComponent }, { path: ':id/version/:versionId/edit', component: ModuleVersionEditComponent }, - { path: ':id/version/:versionId/overlap', component: SimilarModulesPage } + { path: ':id/version/:versionId/overlap', component: SimilarModulesPage }, + { path: ':id/version/:versionId/ai-review', component: ProposalAiReviewPageComponent } ] }, { @@ -41,7 +43,8 @@ export const routes: Routes = [ children: [ { path: '', component: ApprovalStaffHomePageComponent }, { path: 'view/:id', component: FeedbackViewComponent }, - { path: 'view/:id/overlap/:versionId', component: SimilarModulesPage } + { path: 'view/:id/overlap/:versionId', component: SimilarModulesPage }, + { path: 'view/:id/ai-review/:versionId', component: ProposalAiReviewPageComponent } ] }, { diff --git a/Client/src/app/components/breadcrumb/breadcrumb.component.ts b/Client/src/app/components/breadcrumb/breadcrumb.component.ts index f9d11fd1..35d1a408 100644 --- a/Client/src/app/components/breadcrumb/breadcrumb.component.ts +++ b/Client/src/app/components/breadcrumb/breadcrumb.component.ts @@ -27,13 +27,15 @@ export class BreadcrumbComponent { showBreadcrumb = computed(() => { const u = this.url(); - return u.startsWith('/proposals') || u.startsWith('/feedbacks') || u.startsWith('/admin'); + return u.startsWith('/proposals') || u.startsWith('/feedbacks') || u.startsWith('/admin') + || u.startsWith('/ai-review-guidelines'); }); private buildItems(url: string): MenuItem[] { if (url.startsWith('/proposals')) return this.buildProposalItems(url); if (url.startsWith('/feedbacks')) return this.buildFeedbackItems(url); if (url.startsWith('/admin')) return this.buildAdminItems(url); + if (url.startsWith('/ai-review-guidelines')) return this.buildAiReviewGuidelinesItems(); return []; } @@ -132,6 +134,13 @@ export class BreadcrumbComponent { }); return items; } + if (segments[4] === 'ai-review') { + items.push({ + label: 'AI Review', + routerLink: ['/proposals', proposalId, 'version', versionId, 'ai-review'] + }); + return items; + } } return items; @@ -159,6 +168,17 @@ export class BreadcrumbComponent { }); } + if (segments[3] === 'ai-review' && segments[4]) { + items.push({ + label: 'AI Review', + routerLink: ['/feedbacks/view', segments[2], 'ai-review', segments[4]] + }); + } + return items; } + + private buildAiReviewGuidelinesItems(): MenuItem[] { + return [{ label: 'AI Review Guidelines', routerLink: ['/ai-review-guidelines'] }]; + } } diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.html b/Client/src/app/components/create-edit-base/create-edit-base.component.html index e63fe5a3..a226c536 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.html +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.html @@ -14,6 +14,24 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' + @if (moduleVersionId && moduleVersionDto()?.proposalId; as proposalId) { +
+ + +
+ } +
@if (currentStepIndex() === 0) {
diff --git a/Client/src/app/core/modules/openapi/.openapi-generator/FILES b/Client/src/app/core/modules/openapi/.openapi-generator/FILES index bf8ebc57..cc4b96f2 100644 --- a/Client/src/app/core/modules/openapi/.openapi-generator/FILES +++ b/Client/src/app/core/modules/openapi/.openapi-generator/FILES @@ -49,6 +49,8 @@ model/module-version-update-request-dto.ts model/module-version-view-dto.ts model/module-version-view-feedback-dto.ts model/page-response-dto-user-dto.ts +model/proposal-ai-review-dto.ts +model/proposal-ai-review-section-dto.ts model/proposal-request-dto.ts model/proposal-view-dto.ts model/proposals-compact-dto.ts diff --git a/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts b/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts index 936209b4..87f63396 100644 --- a/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts +++ b/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts @@ -27,6 +27,8 @@ import { ModuleVersionViewDTO } from '../model/module-version-view-dto'; // @ts-ignore import { ModuleVersionViewFeedbackDTO } from '../model/module-version-view-feedback-dto'; // @ts-ignore +import { ProposalAiReviewDTO } from '../model/proposal-ai-review-dto'; +// @ts-ignore import { SimilarModuleDTO } from '../model/similar-module-dto'; // @ts-ignore @@ -636,6 +638,77 @@ export class ModuleVersionControllerService implements ModuleVersionControllerSe ); } + /** + * @param moduleVersionId + * @param regenerate + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getProposalAiReview(moduleVersionId: number, regenerate?: boolean, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getProposalAiReview(moduleVersionId: number, regenerate?: boolean, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getProposalAiReview(moduleVersionId: number, regenerate?: boolean, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getProposalAiReview(moduleVersionId: number, regenerate?: boolean, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (moduleVersionId === null || moduleVersionId === undefined) { + throw new Error('Required parameter moduleVersionId was null or undefined when calling getProposalAiReview.'); + } + + let localVarQueryParameters = new HttpParams({encoder: this.encoder}); + if (regenerate !== undefined && regenerate !== null) { + localVarQueryParameters = this.addToHttpParams(localVarQueryParameters, + regenerate, 'regenerate'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/module-versions/${this.configuration.encodeParam({name: "moduleVersionId", value: moduleVersionId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}/ai-review`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + params: localVarQueryParameters, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + /** * @param moduleVersionId * @param moduleVersionUpdateRequestDTO diff --git a/Client/src/app/core/modules/openapi/api/module-version-controller.serviceInterface.ts b/Client/src/app/core/modules/openapi/api/module-version-controller.serviceInterface.ts index 21024e91..9370a5b7 100644 --- a/Client/src/app/core/modules/openapi/api/module-version-controller.serviceInterface.ts +++ b/Client/src/app/core/modules/openapi/api/module-version-controller.serviceInterface.ts @@ -16,6 +16,7 @@ import { CompletionServiceResponseDTO } from '../model/models'; import { ModuleVersionUpdateRequestDTO } from '../model/models'; import { ModuleVersionViewDTO } from '../model/models'; import { ModuleVersionViewFeedbackDTO } from '../model/models'; +import { ProposalAiReviewDTO } from '../model/models'; import { SimilarModuleDTO } from '../model/models'; @@ -83,6 +84,14 @@ export interface ModuleVersionControllerServiceInterface { */ getPreviousModuleVersionFeedback(id: number, extraHttpRequestParams?: any): Observable>; + /** + * + * + * @param moduleVersionId + * @param regenerate + */ + getProposalAiReview(moduleVersionId: number, regenerate?: boolean, extraHttpRequestParams?: any): Observable; + /** * * diff --git a/Client/src/app/core/modules/openapi/model/feedback.ts b/Client/src/app/core/modules/openapi/model/feedback.ts index 83a6410f..90487c76 100644 --- a/Client/src/app/core/modules/openapi/model/feedback.ts +++ b/Client/src/app/core/modules/openapi/model/feedback.ts @@ -73,8 +73,8 @@ export interface Feedback { responsiblesAccepted?: boolean; lvSwsLecturerFeedback?: string; lvSwsLecturerAccepted?: boolean; - feedbackGiven?: boolean; allFeedbackPositive?: boolean; + feedbackGiven?: boolean; comment?: string; } export namespace Feedback { diff --git a/Client/src/app/core/modules/openapi/model/models.ts b/Client/src/app/core/modules/openapi/model/models.ts index 2a071cd2..fafa8ec2 100644 --- a/Client/src/app/core/modules/openapi/model/models.ts +++ b/Client/src/app/core/modules/openapi/model/models.ts @@ -21,6 +21,8 @@ export * from './module-version-update-request-dto'; export * from './module-version-view-dto'; export * from './module-version-view-feedback-dto'; export * from './page-response-dto-user-dto'; +export * from './proposal-ai-review-dto'; +export * from './proposal-ai-review-section-dto'; export * from './proposal-request-dto'; export * from './proposal-view-dto'; export * from './proposals-compact-dto'; diff --git a/Client/src/app/core/modules/openapi/model/proposal-ai-review-dto.ts b/Client/src/app/core/modules/openapi/model/proposal-ai-review-dto.ts new file mode 100644 index 00000000..2e5b331d --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/proposal-ai-review-dto.ts @@ -0,0 +1,20 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { ProposalAiReviewSectionDTO } from './proposal-ai-review-section-dto'; + + +export interface ProposalAiReviewDTO { + moduleVersionId?: number; + summary?: string; + sections?: Array; + generatedAt?: string; + guidelinesConfigured?: boolean; +} + diff --git a/Client/src/app/core/modules/openapi/model/proposal-ai-review-section-dto.ts b/Client/src/app/core/modules/openapi/model/proposal-ai-review-section-dto.ts new file mode 100644 index 00000000..9a488dd6 --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/proposal-ai-review-section-dto.ts @@ -0,0 +1,60 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface ProposalAiReviewSectionDTO { + section?: ProposalAiReviewSectionDTO.SectionEnum; + sectionLabel?: string; + severity?: ProposalAiReviewSectionDTO.SeverityEnum; + findings?: string; + suggestions?: string; +} +export namespace ProposalAiReviewSectionDTO { + export type SectionEnum = 'GENERAL' | 'TITLE_ENG' | 'TITLE_DE' | 'LEVEL_ENG' | 'LANGUAGE_ENG' | 'FREQUENCY_ENG' | 'CREDITS' | 'DURATION' | 'HOURS_LECTURE' | 'HOURS_EXERCISE' | 'HOURS_PRACTICAL' | 'HOURS_SEMINAR' | 'FIRST_SEMESTER_AVAILABLE' | 'SUCCESSOR_MODULE_NAME' | 'HOURS_TOTAL' | 'HOURS_SELF_STUDY' | 'HOURS_PRESENCE' | 'BULLET_POINTS' | 'EXAMINATION_ACHIEVEMENTS' | 'REPETITION' | 'RECOMMENDED_PREREQUISITES' | 'CONTENT' | 'LEARNING_OUTCOMES' | 'TEACHING_METHODS' | 'MEDIA' | 'LITERATURE' | 'RESPONSIBLES' | 'LV_SWS_LECTURER' | 'DEGREE_PROGRAM_ASSIGNMENTS'; + export const SectionEnum = { + General: 'GENERAL' as SectionEnum, + TitleEng: 'TITLE_ENG' as SectionEnum, + TitleDe: 'TITLE_DE' as SectionEnum, + LevelEng: 'LEVEL_ENG' as SectionEnum, + LanguageEng: 'LANGUAGE_ENG' as SectionEnum, + FrequencyEng: 'FREQUENCY_ENG' as SectionEnum, + Credits: 'CREDITS' as SectionEnum, + Duration: 'DURATION' as SectionEnum, + HoursLecture: 'HOURS_LECTURE' as SectionEnum, + HoursExercise: 'HOURS_EXERCISE' as SectionEnum, + HoursPractical: 'HOURS_PRACTICAL' as SectionEnum, + HoursSeminar: 'HOURS_SEMINAR' as SectionEnum, + FirstSemesterAvailable: 'FIRST_SEMESTER_AVAILABLE' as SectionEnum, + SuccessorModuleName: 'SUCCESSOR_MODULE_NAME' as SectionEnum, + HoursTotal: 'HOURS_TOTAL' as SectionEnum, + HoursSelfStudy: 'HOURS_SELF_STUDY' as SectionEnum, + HoursPresence: 'HOURS_PRESENCE' as SectionEnum, + BulletPoints: 'BULLET_POINTS' as SectionEnum, + ExaminationAchievements: 'EXAMINATION_ACHIEVEMENTS' as SectionEnum, + Repetition: 'REPETITION' as SectionEnum, + RecommendedPrerequisites: 'RECOMMENDED_PREREQUISITES' as SectionEnum, + Content: 'CONTENT' as SectionEnum, + LearningOutcomes: 'LEARNING_OUTCOMES' as SectionEnum, + TeachingMethods: 'TEACHING_METHODS' as SectionEnum, + Media: 'MEDIA' as SectionEnum, + Literature: 'LITERATURE' as SectionEnum, + Responsibles: 'RESPONSIBLES' as SectionEnum, + LvSwsLecturer: 'LV_SWS_LECTURER' as SectionEnum, + DegreeProgramAssignments: 'DEGREE_PROGRAM_ASSIGNMENTS' as SectionEnum + }; + export type SeverityEnum = 'OK' | 'ATTENTION' | 'CRITICAL'; + export const SeverityEnum = { + Ok: 'OK' as SeverityEnum, + Attention: 'ATTENTION' as SeverityEnum, + Critical: 'CRITICAL' as SeverityEnum + }; +} + + diff --git a/Client/src/app/pages/feedback-view/feedback-view.component.html b/Client/src/app/pages/feedback-view/feedback-view.component.html index 1cb05d50..e95f65d3 100644 --- a/Client/src/app/pages/feedback-view/feedback-view.component.html +++ b/Client/src/app/pages/feedback-view/feedback-view.component.html @@ -16,7 +16,8 @@

Module Proposal for '{{ moduleVersion.titleEng }}'

- + +
diff --git a/Client/src/app/pages/module-version-view/module-version-view.component.html b/Client/src/app/pages/module-version-view/module-version-view.component.html index 49522ec7..90df7b1f 100644 --- a/Client/src/app/pages/module-version-view/module-version-view.component.html +++ b/Client/src/app/pages/module-version-view/module-version-view.component.html @@ -22,7 +22,8 @@

Overview of '{{ moduleVersionDto.titleEng }}' - Version {{
@if (proposalId) { - + + } @if (isLatestVersion() && proposalId) { diff --git a/Client/src/app/pages/proposal-ai-review/proposal-ai-review-page.component.html b/Client/src/app/pages/proposal-ai-review/proposal-ai-review-page.component.html new file mode 100644 index 00000000..7af805a6 --- /dev/null +++ b/Client/src/app/pages/proposal-ai-review/proposal-ai-review-page.component.html @@ -0,0 +1,97 @@ +

+ + AI-assisted review + @if (review(); as r) { + @if (issueCounts().critical > 0) { + + } + @if (issueCounts().attention > 0) { + + } + } +

+

{{ introText }}

+ +@if (loading()) { +
+ + {{ loadingMessage() }} +
+} @else if (error()) { + +
+

{{ error() }}

+ +
+
+} @else if (review(); as r) { +
+ + +
+ + @if (r.guidelinesConfigured === false) { + +
+ +
+

No review guidelines configured

+

This review is based on generic academic standards only. Ask an AI review guideline manager to configure guidelines for more targeted feedback.

+
+
+
+ } + + +
+

Overall summary

+

{{ r.summary }}

+ @if (r.generatedAt) { +

Generated {{ r.generatedAt | date: 'yyyy-MM-dd HH:mm' }}

+ } +
+
+ + @if (displayedSections().length === 0) { + +
+ +
+

No issues in this filter

+

Switch to “All sections” to see OK items, or regenerate after updating the proposal.

+
+
+
+ } @else { +
+ + @for (section of displayedSections(); track section.section; let i = $index) { + + +
+ {{ sectionLabel(section) }} + +
+
+ + @if (section.findings) { +

Findings

+

{{ section.findings }}

+ } + @if (section.suggestions) { +

Suggestions

+

{{ section.suggestions }}

+ } +
+
+ } +
+
+ } +} diff --git a/Client/src/app/pages/proposal-ai-review/proposal-ai-review-page.component.ts b/Client/src/app/pages/proposal-ai-review/proposal-ai-review-page.component.ts new file mode 100644 index 00000000..73d197c2 --- /dev/null +++ b/Client/src/app/pages/proposal-ai-review/proposal-ai-review-page.component.ts @@ -0,0 +1,150 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; +import { AccordionModule } from 'primeng/accordion'; +import { ButtonModule } from 'primeng/button'; +import { MessageModule } from 'primeng/message'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; +import { SelectButtonModule } from 'primeng/selectbutton'; +import { TagModule } from 'primeng/tag'; +import { + ModuleVersionControllerService, + ProposalAiReviewDTO, + ProposalAiReviewSectionDTO +} from '../../core/modules/openapi'; +import { getProposalReviewSectionLabel } from '../../core/shared/proposal-review-section.utils'; + +type IssueFilter = 'all' | 'issues'; + +@Component({ + selector: 'app-proposal-ai-review-page', + standalone: true, + imports: [ + FormsModule, + DatePipe, + ButtonModule, + MessageModule, + TagModule, + AccordionModule, + ProgressSpinnerModule, + SelectButtonModule + ], + templateUrl: './proposal-ai-review-page.component.html' +}) +export class ProposalAiReviewPageComponent { + private readonly route = inject(ActivatedRoute); + private readonly moduleVersionService = inject(ModuleVersionControllerService); + + review = signal(null); + loading = signal(false); + /** True when regenerating an existing review (vs first load/generation). */ + regenerating = signal(false); + error = signal(null); + issueFilter = signal('issues'); + private moduleVersionId = signal(null); + + readonly introText = + 'AI-assisted review of this proposal against the shared review guidelines—highlighting strengths, gaps, and actionable improvements.'; + + filterOptions = [ + { label: 'Issues only', value: 'issues' as IssueFilter }, + { label: 'All sections', value: 'all' as IssueFilter } + ]; + + loadingMessage = computed(() => + this.regenerating() + ? 'Regenerating the review… This may take a minute.' + : 'Analyzing the proposal against review guidelines… This may take a minute.' + ); + + displayedSections = computed(() => { + const sections = this.review()?.sections ?? []; + if (this.issueFilter() === 'all') { + return sections; + } + return sections.filter((s) => s.severity !== ProposalAiReviewSectionDTO.SeverityEnum.Ok); + }); + + issueCounts = computed(() => { + const sections = this.review()?.sections ?? []; + return { + critical: sections.filter((s) => s.severity === ProposalAiReviewSectionDTO.SeverityEnum.Critical).length, + attention: sections.filter((s) => s.severity === ProposalAiReviewSectionDTO.SeverityEnum.Attention).length, + ok: sections.filter((s) => s.severity === ProposalAiReviewSectionDTO.SeverityEnum.Ok).length + }; + }); + + constructor() { + this.route.params.subscribe((params) => { + const moduleVersionId = Number(params['versionId']); + if (moduleVersionId) { + this.moduleVersionId.set(moduleVersionId); + this.loadReview(moduleVersionId); + } + }); + } + + /** Loads stored review, or generates one automatically if none exists yet. */ + async loadReview(moduleVersionId: number, regenerate = false) { + this.loading.set(true); + this.regenerating.set(regenerate); + this.error.set(null); + try { + const result = await firstValueFrom( + this.moduleVersionService.getProposalAiReview(moduleVersionId, regenerate) + ); + this.review.set(result); + } catch (err) { + this.error.set(this.errorMessage(err)); + } finally { + this.loading.set(false); + this.regenerating.set(false); + } + } + + regenerate() { + const id = this.moduleVersionId() ?? Number(this.route.snapshot.paramMap.get('versionId')); + if (id) { + this.loadReview(id, true); + } + } + + private errorMessage(err: unknown): string { + return err instanceof HttpErrorResponse + ? (typeof err.error === 'string' && err.error ? err.error : err.message) + : 'Could not generate the AI review. Check that the LLM is configured and try again.'; + } + + sectionLabel(section: ProposalAiReviewSectionDTO): string { + return section.sectionLabel ?? getProposalReviewSectionLabel(section.section as never) ?? section.section ?? 'Section'; + } + + severityLabel(severity: ProposalAiReviewSectionDTO.SeverityEnum | undefined): string { + switch (severity) { + case ProposalAiReviewSectionDTO.SeverityEnum.Critical: + return 'Critical'; + case ProposalAiReviewSectionDTO.SeverityEnum.Attention: + return 'Attention'; + case ProposalAiReviewSectionDTO.SeverityEnum.Ok: + return 'OK'; + default: + return 'Unknown'; + } + } + + severityTag(severity: ProposalAiReviewSectionDTO.SeverityEnum | undefined): 'danger' | 'warn' | 'success' | 'secondary' { + switch (severity) { + case ProposalAiReviewSectionDTO.SeverityEnum.Critical: + return 'danger'; + case ProposalAiReviewSectionDTO.SeverityEnum.Attention: + return 'warn'; + case ProposalAiReviewSectionDTO.SeverityEnum.Ok: + return 'success'; + default: + return 'secondary'; + } + } +} diff --git a/Client/src/app/pages/proposal-view/proposal-view.component.html b/Client/src/app/pages/proposal-view/proposal-view.component.html index 551fa1fb..f2c0fd4d 100644 --- a/Client/src/app/pages/proposal-view/proposal-view.component.html +++ b/Client/src/app/pages/proposal-view/proposal-view.component.html @@ -83,6 +83,14 @@

Current Module Versions

+ Previous Module Versions
+ generateTeachingMethods( return ResponseEntity.ok(new CompletionServiceResponseDTO(response)); } + @GetMapping("/{moduleVersionId}/ai-review") + @PreAuthorize("hasAnyRole('PROFESSOR', 'QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD', 'PROGRAM_COORDINATOR', 'SPECIALIZATION_AREA_COORDINATOR')") + public ResponseEntity getProposalAiReview( + @CurrentUser User user, + @PathVariable Long moduleVersionId, + @RequestParam(defaultValue = "false") boolean regenerate) { + if (regenerate) { + log.info("Regenerating AI review for module version {}", moduleVersionId); + } + return ResponseEntity.ok(proposalAiReviewService.getOrGenerateReview(moduleVersionId, user, regenerate)); + } + @PostMapping("/overlap-detection/check-similarity/{moduleVersionId}") @PreAuthorize("hasAnyRole('PROFESSOR', 'QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD')") public ResponseEntity> checkSimilarity(@CurrentUser User user, diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewDTO.java new file mode 100644 index 00000000..282cd3b3 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewDTO.java @@ -0,0 +1,17 @@ +package modulemanagement.ls1.dtos; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Data +public class ProposalAiReviewDTO { + private Long moduleVersionId; + private String summary; + private List sections = new ArrayList<>(); + private LocalDateTime generatedAt; + /** False when no guidelines existed at generation time; review then relies on generic standards only. */ + private boolean guidelinesConfigured; +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewSectionDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewSectionDTO.java new file mode 100644 index 00000000..74deb79f --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewSectionDTO.java @@ -0,0 +1,14 @@ +package modulemanagement.ls1.dtos; + +import lombok.Data; +import modulemanagement.ls1.enums.AiReviewSeverity; +import modulemanagement.ls1.enums.ProposalReviewSection; + +@Data +public class ProposalAiReviewSectionDTO { + private ProposalReviewSection section; + private String sectionLabel; + private AiReviewSeverity severity; + private String findings; + private String suggestions; +} diff --git a/Server/src/main/java/modulemanagement/ls1/enums/AiReviewSeverity.java b/Server/src/main/java/modulemanagement/ls1/enums/AiReviewSeverity.java new file mode 100644 index 00000000..eabb96c9 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/enums/AiReviewSeverity.java @@ -0,0 +1,7 @@ +package modulemanagement.ls1.enums; + +public enum AiReviewSeverity { + OK, + ATTENTION, + CRITICAL +} diff --git a/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReview.java b/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReview.java new file mode 100644 index 00000000..6dec73bc --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReview.java @@ -0,0 +1,47 @@ +package modulemanagement.ls1.models; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Persisted result of an AI proposal review. One row per module version; + * regenerating replaces the previous result. + */ +@Data +@NoArgsConstructor +@Entity +@Table(name = "proposal_ai_review", uniqueConstraints = @UniqueConstraint(columnNames = { "module_version_id" })) +public class ProposalAiReview { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "review_id") + private Long reviewId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "module_version_id", nullable = false) + private ModuleVersion moduleVersion; + + @Column(name = "summary", columnDefinition = "CLOB") + private String summary; + + /** Whether any guidelines existed when this review was generated. */ + @Column(name = "guidelines_configured", nullable = false) + private boolean guidelinesConfigured; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "generated_by") + private User generatedBy; + + @Column(name = "generated_at", nullable = false) + private LocalDateTime generatedAt; + + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("sortOrder ASC") + private List sections = new ArrayList<>(); +} diff --git a/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReviewSection.java b/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReviewSection.java new file mode 100644 index 00000000..3493ca3c --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReviewSection.java @@ -0,0 +1,43 @@ +package modulemanagement.ls1.models; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.NoArgsConstructor; +import modulemanagement.ls1.enums.AiReviewSeverity; +import modulemanagement.ls1.enums.ProposalReviewSection; + +@Data +@NoArgsConstructor +@Entity +@Table(name = "proposal_ai_review_section") +public class ProposalAiReviewSection { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "section_id") + private Long sectionId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private ProposalAiReview review; + + @Enumerated(EnumType.STRING) + @Column(name = "section", nullable = false) + @NotNull + private ProposalReviewSection section; + + @Enumerated(EnumType.STRING) + @Column(name = "severity", nullable = false) + @NotNull + private AiReviewSeverity severity; + + @Column(name = "findings", columnDefinition = "CLOB") + private String findings; + + @Column(name = "suggestions", columnDefinition = "CLOB") + private String suggestions; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; +} diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java index 33b2122d..dc4a5612 100644 --- a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java +++ b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java @@ -15,4 +15,7 @@ public interface FeedbackRepository extends JpaRepository { /** All feedbacks for a proposal that are not invalidated (for display on view/edit). */ List findByModuleVersion_Proposal_ProposalIdAndInvalidatedFalse(Long proposalId); + + boolean existsByModuleVersion_Proposal_ProposalIdAndInvalidatedFalseAndAssignedReviewer_UserId( + Long proposalId, UUID userId); } diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/ProposalAiReviewRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/ProposalAiReviewRepository.java new file mode 100644 index 00000000..fcdd8b0d --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/repositories/ProposalAiReviewRepository.java @@ -0,0 +1,11 @@ +package modulemanagement.ls1.repositories; + +import modulemanagement.ls1.models.ProposalAiReview; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ProposalAiReviewRepository extends JpaRepository { + + Optional findByModuleVersion_ModuleVersionId(Long moduleVersionId); +} diff --git a/Server/src/main/java/modulemanagement/ls1/services/ProposalAiReviewService.java b/Server/src/main/java/modulemanagement/ls1/services/ProposalAiReviewService.java new file mode 100644 index 00000000..a104283e --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/services/ProposalAiReviewService.java @@ -0,0 +1,166 @@ +package modulemanagement.ls1.services; + +import modulemanagement.ls1.dtos.ProposalAiReviewDTO; +import modulemanagement.ls1.dtos.ProposalAiReviewSectionDTO; +import modulemanagement.ls1.enums.UserRole; +import modulemanagement.ls1.models.AiReviewGuideline; +import modulemanagement.ls1.models.ModuleVersion; +import modulemanagement.ls1.models.Proposal; +import modulemanagement.ls1.models.ProposalAiReview; +import modulemanagement.ls1.models.ProposalAiReviewSection; +import modulemanagement.ls1.models.User; +import modulemanagement.ls1.repositories.AiReviewGuidelineRepository; +import modulemanagement.ls1.repositories.FeedbackRepository; +import modulemanagement.ls1.repositories.ModuleVersionRepository; +import modulemanagement.ls1.repositories.ProposalAiReviewRepository; +import modulemanagement.ls1.shared.AiReviewGuidelinePromptUtil; +import modulemanagement.ls1.shared.ProposalAiReviewParser; +import modulemanagement.ls1.shared.ProposalAiReviewPromptUtil; +import modulemanagement.ls1.shared.ResourceNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Service +public class ProposalAiReviewService { + + private final ModuleVersionRepository moduleVersionRepository; + private final AiReviewGuidelineRepository aiReviewGuidelineRepository; + private final FeedbackRepository feedbackRepository; + private final ProposalAiReviewRepository proposalAiReviewRepository; + private final LLMGenerationService llmGenerationService; + + public ProposalAiReviewService(ModuleVersionRepository moduleVersionRepository, + AiReviewGuidelineRepository aiReviewGuidelineRepository, + FeedbackRepository feedbackRepository, + ProposalAiReviewRepository proposalAiReviewRepository, + LLMGenerationService llmGenerationService) { + this.moduleVersionRepository = moduleVersionRepository; + this.aiReviewGuidelineRepository = aiReviewGuidelineRepository; + this.feedbackRepository = feedbackRepository; + this.proposalAiReviewRepository = proposalAiReviewRepository; + this.llmGenerationService = llmGenerationService; + } + + /** + * Returns the stored AI review, or generates one if none exists yet. + * Pass {@code regenerate=true} to force a fresh LLM run and replace the stored result. + */ + @Transactional + public ProposalAiReviewDTO getOrGenerateReview(Long moduleVersionId, User user, boolean regenerate) { + if (!regenerate) { + requireAccessibleModuleVersion(moduleVersionId, user); + Optional stored = proposalAiReviewRepository + .findByModuleVersion_ModuleVersionId(moduleVersionId) + .map(this::toDto); + if (stored.isPresent()) { + return stored.get(); + } + } + return generateReview(moduleVersionId, user); + } + + /** Generates a fresh review, replacing any stored one for this module version. */ + @Transactional + public ProposalAiReviewDTO generateReview(Long moduleVersionId, User user) { + ModuleVersion moduleVersion = requireAccessibleModuleVersion(moduleVersionId, user); + List guidelines = aiReviewGuidelineRepository + .findAllByOrderBySectionAscSortOrderAscGuidelineIdAsc(); + + String basePrompt = ProposalAiReviewPromptUtil.buildReviewPrompt(moduleVersion, guidelines); + String prompt = ProposalAiReviewPromptUtil.appendSectionGuidelines(basePrompt, guidelines); + String llmResponse = llmGenerationService.generate(prompt, "proposal-ai-review"); + + ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(llmResponse); + dto.setModuleVersionId(moduleVersionId); + dto.setGeneratedAt(LocalDateTime.now()); + dto.setGuidelinesConfigured(!guidelines.isEmpty()); + + persistReview(moduleVersion, user, dto); + return dto; + } + + private void persistReview(ModuleVersion moduleVersion, User user, ProposalAiReviewDTO dto) { + ProposalAiReview review = proposalAiReviewRepository + .findByModuleVersion_ModuleVersionId(moduleVersion.getModuleVersionId()) + .orElseGet(ProposalAiReview::new); + review.setModuleVersion(moduleVersion); + review.setSummary(dto.getSummary()); + review.setGuidelinesConfigured(dto.isGuidelinesConfigured()); + review.setGeneratedBy(user); + review.setGeneratedAt(dto.getGeneratedAt()); + + review.getSections().clear(); + int order = 0; + for (ProposalAiReviewSectionDTO sectionDto : dto.getSections()) { + ProposalAiReviewSection section = new ProposalAiReviewSection(); + section.setReview(review); + section.setSection(sectionDto.getSection()); + section.setSeverity(sectionDto.getSeverity()); + section.setFindings(sectionDto.getFindings()); + section.setSuggestions(sectionDto.getSuggestions()); + section.setSortOrder(order++); + review.getSections().add(section); + } + proposalAiReviewRepository.save(review); + } + + private ProposalAiReviewDTO toDto(ProposalAiReview review) { + ProposalAiReviewDTO dto = new ProposalAiReviewDTO(); + dto.setModuleVersionId(review.getModuleVersion().getModuleVersionId()); + dto.setSummary(review.getSummary()); + dto.setGeneratedAt(review.getGeneratedAt()); + dto.setGuidelinesConfigured(review.isGuidelinesConfigured()); + List sections = new ArrayList<>(); + for (ProposalAiReviewSection section : review.getSections()) { + ProposalAiReviewSectionDTO sectionDto = new ProposalAiReviewSectionDTO(); + sectionDto.setSection(section.getSection()); + sectionDto.setSectionLabel(AiReviewGuidelinePromptUtil.getSectionLabel(section.getSection())); + sectionDto.setSeverity(section.getSeverity()); + sectionDto.setFindings(section.getFindings()); + sectionDto.setSuggestions(section.getSuggestions()); + sections.add(sectionDto); + } + dto.setSections(sections); + return dto; + } + + private ModuleVersion requireAccessibleModuleVersion(Long moduleVersionId, User user) { + ModuleVersion moduleVersion = moduleVersionRepository.findById(moduleVersionId) + .orElseThrow(() -> new ResourceNotFoundException("Could not find a module version with this ID.")); + if (!canAccessModuleVersionForAiReview(moduleVersion, user)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You do not have access to review this proposal."); + } + return moduleVersion; + } + + private boolean canAccessModuleVersionForAiReview(ModuleVersion moduleVersion, User user) { + Proposal proposal = moduleVersion.getProposal(); + if (proposal == null || proposal.getCreatedBy() == null) { + return false; + } + if (proposal.getCreatedBy().getUserId().equals(user.getUserId())) { + return true; + } + if (user.getRoles() == null) { + return false; + } + if (user.getRoles().contains(UserRole.QUALITY_MANAGEMENT) + || user.getRoles().contains(UserRole.EXAMINATION_BOARD) + || user.getRoles().contains(UserRole.ACADEMIC_PROGRAM_ADVISOR)) { + return true; + } + if (user.getRoles().contains(UserRole.PROGRAM_COORDINATOR) + || user.getRoles().contains(UserRole.SPECIALIZATION_AREA_COORDINATOR)) { + return feedbackRepository.existsByModuleVersion_Proposal_ProposalIdAndInvalidatedFalseAndAssignedReviewer_UserId( + proposal.getProposalId(), user.getUserId()); + } + return false; + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewParser.java b/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewParser.java new file mode 100644 index 00000000..befa0f20 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewParser.java @@ -0,0 +1,130 @@ +package modulemanagement.ls1.shared; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import modulemanagement.ls1.dtos.ProposalAiReviewDTO; +import modulemanagement.ls1.dtos.ProposalAiReviewSectionDTO; +import modulemanagement.ls1.enums.AiReviewSeverity; +import modulemanagement.ls1.enums.ProposalReviewSection; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Parses the LLM JSON response of a proposal review into a {@link ProposalAiReviewDTO}. + * Tolerates markdown code fences, surrounding prose, unknown section names, and + * missing/invalid severities. Falls back to using the raw response as summary when + * no JSON object can be parsed at all. + */ +public final class ProposalAiReviewParser { + + public static final String FALLBACK_SUMMARY = "Review completed. See section details below."; + public static final String UNPARSEABLE_SUMMARY = "Review could not be parsed."; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private ProposalAiReviewParser() { + } + + public static ProposalAiReviewDTO parse(String llmResponse) { + ProposalAiReviewDTO dto = new ProposalAiReviewDTO(); + if (llmResponse == null || llmResponse.isBlank()) { + dto.setSummary(UNPARSEABLE_SUMMARY); + dto.setSections(List.of()); + return dto; + } + try { + String json = extractJson(llmResponse); + JsonNode root = OBJECT_MAPPER.readTree(json); + if (!root.isObject()) { + throw new IllegalArgumentException("Response is not a JSON object"); + } + dto.setSummary(textOrDefault(root.get("summary"), FALLBACK_SUMMARY)); + dto.setSections(parseSections(root.get("sections"))); + } catch (Exception e) { + String raw = llmResponse != null ? llmResponse.trim() : ""; + dto.setSummary(raw.isEmpty() ? UNPARSEABLE_SUMMARY : raw); + dto.setSections(List.of()); + } + return dto; + } + + private static List parseSections(JsonNode sectionsNode) { + List sections = new ArrayList<>(); + if (sectionsNode == null || !sectionsNode.isArray()) { + return sections; + } + for (JsonNode node : sectionsNode) { + ProposalAiReviewSectionDTO section = parseSectionNode(node); + if (section != null) { + sections.add(section); + } + } + sections.sort(Comparator.comparingInt(s -> severityOrder(s.getSeverity()))); + return sections; + } + + private static ProposalAiReviewSectionDTO parseSectionNode(JsonNode node) { + if (node == null || !node.has("section")) { + return null; + } + ProposalAiReviewSectionDTO section = new ProposalAiReviewSectionDTO(); + try { + section.setSection(ProposalReviewSection.valueOf(node.get("section").asText().trim().toUpperCase())); + } catch (IllegalArgumentException e) { + return null; + } + section.setSectionLabel(AiReviewGuidelinePromptUtil.getSectionLabel(section.getSection())); + section.setSeverity(parseSeverity(node.get("severity"))); + section.setFindings(textOrDefault(node.get("findings"), "")); + section.setSuggestions(textOrDefault(node.get("suggestions"), "")); + return section; + } + + private static AiReviewSeverity parseSeverity(JsonNode node) { + if (node == null || node.isNull()) { + return AiReviewSeverity.ATTENTION; + } + try { + return AiReviewSeverity.valueOf(node.asText().trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return AiReviewSeverity.ATTENTION; + } + } + + private static String textOrDefault(JsonNode node, String defaultValue) { + if (node == null || node.isNull()) { + return defaultValue; + } + String text = node.asText().trim(); + return text.isEmpty() ? defaultValue : text; + } + + /** + * Extracts the outermost JSON object from a response that may contain code fences + * or surrounding prose. + */ + static String extractJson(String response) { + if (response == null) { + return "{}"; + } + String trimmed = response.trim(); + int start = trimmed.indexOf('{'); + int end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + return trimmed.substring(start, end + 1); + } + return trimmed; + } + + public static int severityOrder(AiReviewSeverity severity) { + if (severity == AiReviewSeverity.CRITICAL) { + return 0; + } + if (severity == AiReviewSeverity.ATTENTION) { + return 1; + } + return 2; + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtil.java b/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtil.java new file mode 100644 index 00000000..04a921c7 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtil.java @@ -0,0 +1,117 @@ +package modulemanagement.ls1.shared; + +import modulemanagement.ls1.enums.ProposalReviewSection; +import modulemanagement.ls1.models.AiReviewGuideline; +import modulemanagement.ls1.models.ModuleVersion; +import modulemanagement.ls1.models.ModuleVersionDegreeProgramAssignment; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public final class ProposalAiReviewPromptUtil { + + private static final List REVIEWABLE_SECTIONS = Arrays.stream(ProposalReviewSection.values()) + .filter(s -> s != ProposalReviewSection.GENERAL) + .toList(); + + private ProposalAiReviewPromptUtil() { + } + + private static final String REVIEW_INSTRUCTION = """ + Provide an objective academic review suitable for both authors improving the proposal \ + and reviewers assessing it. Highlight strengths, gaps, compliance issues, and actionable \ + improvements. Flag blockers as CRITICAL."""; + + public static String buildReviewPrompt(ModuleVersion moduleVersion, List guidelines) { + String generalGuidelines = AiReviewGuidelinePromptUtil.formatGuidelinesForSection(guidelines, + ProposalReviewSection.GENERAL); + + return """ + You are an expert academic module proposal reviewer at the Technical University of Munich (TUM). + %s + + Review the module proposal below against the provided guidelines. + Only evaluate sections that have submitted content and/or applicable guidelines. + Be concise but thorough. Flag missing critical information as CRITICAL. + + === MODULE PROPOSAL === + %s + + === REVIEW GUIDELINES (apply all relevant rules) === + %s + + === RESPONSE FORMAT === + Respond with valid JSON only (no markdown fences), using this exact structure: + { + "summary": "2-4 sentence overall assessment", + "sections": [ + { + "section": "SECTION_ENUM_NAME", + "severity": "OK|ATTENTION|CRITICAL", + "findings": "what you observed", + "suggestions": "actionable advice (empty string if severity is OK)" + } + ] + } + + Allowed section enum values: %s + Use severity OK when the section meets guidelines; ATTENTION for improvable issues; CRITICAL for blockers or major gaps. + """ + .formatted( + REVIEW_INSTRUCTION, + buildProposalDataBlock(moduleVersion), + generalGuidelines.isEmpty() ? "(No general guidelines configured.)" : generalGuidelines, + REVIEWABLE_SECTIONS.stream().map(Enum::name).collect(Collectors.joining(", "))); + } + + private static String buildProposalDataBlock(ModuleVersion mv) { + List parts = new ArrayList<>(); + for (ProposalReviewSection section : REVIEWABLE_SECTIONS) { + if (section == ProposalReviewSection.DEGREE_PROGRAM_ASSIGNMENTS) { + String assignments = formatDegreeProgramAssignments(mv); + if (!assignments.isBlank()) { + parts.add(AiReviewGuidelinePromptUtil.getSectionLabel(section) + ":\n" + assignments); + } + continue; + } + String value = AiReviewGuidelinePromptUtil.extractFieldValue(mv, section); + if (value != null) { + parts.add(AiReviewGuidelinePromptUtil.getSectionLabel(section) + ":\n" + value); + } + } + if (parts.isEmpty()) { + return "(No proposal fields filled in yet.)"; + } + return String.join("\n\n", parts); + } + + private static String formatDegreeProgramAssignments(ModuleVersion mv) { + if (mv.getDegreeProgramAssignments() == null || mv.getDegreeProgramAssignments().isEmpty()) { + return ""; + } + return mv.getDegreeProgramAssignments().stream() + .map(ProposalAiReviewPromptUtil::formatAssignment) + .collect(Collectors.joining("\n")); + } + + private static String formatAssignment(ModuleVersionDegreeProgramAssignment a) { + String program = a.getDegreeProgram() != null ? a.getDegreeProgram().getName() : "Unknown program"; + String specialization = a.getDegreeProgramSpecialization() != null + ? a.getDegreeProgramSpecialization().getName() + : "Unknown specialization"; + return "- " + program + " / " + specialization; + } + + public static String appendSectionGuidelines(String basePrompt, List guidelines) { + StringBuilder sb = new StringBuilder(basePrompt); + for (ProposalReviewSection section : REVIEWABLE_SECTIONS) { + String sectionRules = AiReviewGuidelinePromptUtil.formatGuidelinesForSection(guidelines, section); + if (!sectionRules.isEmpty()) { + sb.append("\n\n").append(sectionRules); + } + } + return sb.toString(); + } +} diff --git a/Server/src/main/resources/application.yaml b/Server/src/main/resources/application.yaml index 881eb687..05b4e587 100644 --- a/Server/src/main/resources/application.yaml +++ b/Server/src/main/resources/application.yaml @@ -37,6 +37,11 @@ spring: starttls: enable: ${MAIL_STARTTLS_ENABLE:false} required: ${MAIL_STARTTLS_REQUIRED:false} + # Generous timeouts for LLM calls (full proposal AI reviews can take >1 minute on local models). + http: + clients: + connect-timeout: 30s + read-timeout: 5m ai: openai: api-key: ${OPENAI_API_KEY:not-needed} diff --git a/Server/src/main/resources/db/changelog/changes/0018_proposal_ai_review.yaml b/Server/src/main/resources/db/changelog/changes/0018_proposal_ai_review.yaml new file mode 100644 index 00000000..af5a1547 --- /dev/null +++ b/Server/src/main/resources/db/changelog/changes/0018_proposal_ai_review.yaml @@ -0,0 +1,104 @@ +databaseChangeLog: + - changeSet: + id: 0018a_proposal_ai_review_tables + author: module-management + changes: + - createTable: + tableName: proposal_ai_review + columns: + - column: + name: review_id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + - column: + name: module_version_id + type: BIGINT + constraints: + nullable: false + - column: + name: summary + type: CLOB + constraints: + nullable: true + - column: + name: guidelines_configured + type: BOOLEAN + defaultValueBoolean: false + constraints: + nullable: false + - column: + name: generated_by + type: UUID + constraints: + nullable: true + - column: + name: generated_at + type: TIMESTAMP + constraints: + nullable: false + - addUniqueConstraint: + tableName: proposal_ai_review + columnNames: module_version_id + constraintName: uq_proposal_ai_review_module_version + - addForeignKeyConstraint: + baseTableName: proposal_ai_review + baseColumnNames: module_version_id + referencedTableName: module_version + referencedColumnNames: module_version_id + constraintName: fk_proposal_ai_review_module_version + onDelete: CASCADE + - addForeignKeyConstraint: + baseTableName: proposal_ai_review + baseColumnNames: generated_by + referencedTableName: app_user + referencedColumnNames: user_id + constraintName: fk_proposal_ai_review_generated_by + - createTable: + tableName: proposal_ai_review_section + columns: + - column: + name: section_id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + - column: + name: review_id + type: BIGINT + constraints: + nullable: false + - column: + name: section + type: VARCHAR(64) + constraints: + nullable: false + - column: + name: severity + type: VARCHAR(32) + constraints: + nullable: false + - column: + name: findings + type: CLOB + constraints: + nullable: true + - column: + name: suggestions + type: CLOB + constraints: + nullable: true + - column: + name: sort_order + type: INT + defaultValueNumeric: 0 + constraints: + nullable: false + - addForeignKeyConstraint: + baseTableName: proposal_ai_review_section + baseColumnNames: review_id + referencedTableName: proposal_ai_review + referencedColumnNames: review_id + constraintName: fk_proposal_ai_review_section_review + onDelete: CASCADE diff --git a/Server/src/main/resources/db/changelog/master.yaml b/Server/src/main/resources/db/changelog/master.yaml index 38b6dbb3..276614fe 100644 --- a/Server/src/main/resources/db/changelog/master.yaml +++ b/Server/src/main/resources/db/changelog/master.yaml @@ -50,3 +50,6 @@ databaseChangeLog: - include: relativeToChangelogFile: true file: changes/0017_ai_review_guidelines.yaml + - include: + relativeToChangelogFile: true + file: changes/0018_proposal_ai_review.yaml diff --git a/Server/src/test/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtilTest.java b/Server/src/test/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtilTest.java new file mode 100644 index 00000000..efc20048 --- /dev/null +++ b/Server/src/test/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtilTest.java @@ -0,0 +1,81 @@ +package modulemanagement.ls1.shared; + +import modulemanagement.ls1.enums.Language; +import modulemanagement.ls1.enums.ProposalReviewSection; +import modulemanagement.ls1.models.AiReviewGuideline; +import modulemanagement.ls1.models.ModuleVersion; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class AiReviewGuidelinePromptUtilTest { + + private AiReviewGuideline guideline(ProposalReviewSection section, String title, String instruction) { + AiReviewGuideline g = new AiReviewGuideline(); + g.setSection(section); + g.setTitle(title); + g.setInstruction(instruction); + return g; + } + + @Test + void extractsStringAndNumericAndEnumFields() { + ModuleVersion mv = new ModuleVersion(); + mv.setTitleEng("Databases"); + mv.setCredits(6); + mv.setLanguageEng(Language.English); + + assertEquals("Databases", AiReviewGuidelinePromptUtil.extractFieldValue(mv, ProposalReviewSection.TITLE_ENG)); + assertEquals("6", AiReviewGuidelinePromptUtil.extractFieldValue(mv, ProposalReviewSection.CREDITS)); + assertEquals("English", AiReviewGuidelinePromptUtil.extractFieldValue(mv, ProposalReviewSection.LANGUAGE_ENG)); + } + + @Test + void returnsNullForEmptyOrMissingValues() { + ModuleVersion mv = new ModuleVersion(); + mv.setTitleEng(" "); + + assertNull(AiReviewGuidelinePromptUtil.extractFieldValue(mv, ProposalReviewSection.TITLE_ENG)); + assertNull(AiReviewGuidelinePromptUtil.extractFieldValue(mv, ProposalReviewSection.CONTENT)); + assertNull(AiReviewGuidelinePromptUtil.extractFieldValue(null, ProposalReviewSection.CONTENT)); + assertNull(AiReviewGuidelinePromptUtil.extractFieldValue(mv, null)); + } + + @Test + void formatsGuidelinesForMatchingSectionOnly() { + List guidelines = List.of( + guideline(ProposalReviewSection.CONTENT, "Depth", "Three thematic blocks."), + guideline(ProposalReviewSection.MEDIA, "Media rule", "List used media.")); + + String formatted = AiReviewGuidelinePromptUtil.formatGuidelinesForSection(guidelines, + ProposalReviewSection.CONTENT); + + assertTrue(formatted.contains("Depth")); + assertTrue(formatted.contains("Three thematic blocks.")); + assertFalse(formatted.contains("Media rule")); + } + + @Test + void returnsEmptyStringWhenNoGuidelinesMatch() { + List guidelines = List.of( + guideline(ProposalReviewSection.MEDIA, "Media rule", "List used media.")); + + assertEquals("", AiReviewGuidelinePromptUtil.formatGuidelinesForSection(guidelines, + ProposalReviewSection.CONTENT)); + assertEquals("", AiReviewGuidelinePromptUtil.formatGuidelinesForSection(List.of(), + ProposalReviewSection.CONTENT)); + assertEquals("", AiReviewGuidelinePromptUtil.formatGuidelinesForSection(null, + ProposalReviewSection.CONTENT)); + } + + @Test + void everySectionHasALabel() { + for (ProposalReviewSection section : ProposalReviewSection.values()) { + String label = AiReviewGuidelinePromptUtil.getSectionLabel(section); + assertNotNull(label); + assertFalse(label.isBlank()); + } + } +} diff --git a/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewParserTest.java b/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewParserTest.java new file mode 100644 index 00000000..1ce436bd --- /dev/null +++ b/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewParserTest.java @@ -0,0 +1,150 @@ +package modulemanagement.ls1.shared; + +import modulemanagement.ls1.dtos.ProposalAiReviewDTO; +import modulemanagement.ls1.dtos.ProposalAiReviewSectionDTO; +import modulemanagement.ls1.enums.AiReviewSeverity; +import modulemanagement.ls1.enums.ProposalReviewSection; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ProposalAiReviewParserTest { + + @Test + void parsesValidJsonResponse() { + String response = """ + { + "summary": "Solid proposal with minor gaps.", + "sections": [ + {"section": "CONTENT", "severity": "OK", "findings": "Content is well structured.", "suggestions": ""}, + {"section": "CREDITS", "severity": "CRITICAL", "findings": "Credits missing.", "suggestions": "Add ECTS credits."} + ] + } + """; + + ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response); + + assertEquals("Solid proposal with minor gaps.", dto.getSummary()); + assertEquals(2, dto.getSections().size()); + // Sorted by severity: CRITICAL first + assertEquals(ProposalReviewSection.CREDITS, dto.getSections().get(0).getSection()); + assertEquals(AiReviewSeverity.CRITICAL, dto.getSections().get(0).getSeverity()); + assertEquals("Add ECTS credits.", dto.getSections().get(0).getSuggestions()); + assertEquals(ProposalReviewSection.CONTENT, dto.getSections().get(1).getSection()); + assertEquals(AiReviewSeverity.OK, dto.getSections().get(1).getSeverity()); + } + + @Test + void parsesJsonWrappedInMarkdownCodeFence() { + String response = """ + Here is the review: + ```json + {"summary": "Looks good.", "sections": [{"section": "TITLE_ENG", "severity": "ATTENTION", "findings": "Too vague.", "suggestions": "Be specific."}]} + ``` + """; + + ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response); + + assertEquals("Looks good.", dto.getSummary()); + assertEquals(1, dto.getSections().size()); + assertEquals(ProposalReviewSection.TITLE_ENG, dto.getSections().get(0).getSection()); + } + + @Test + void skipsUnknownSectionNames() { + String response = """ + {"summary": "S", "sections": [ + {"section": "NOT_A_REAL_SECTION", "severity": "OK", "findings": "x", "suggestions": ""}, + {"section": "MEDIA", "severity": "OK", "findings": "fine", "suggestions": ""} + ]} + """; + + ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response); + + assertEquals(1, dto.getSections().size()); + assertEquals(ProposalReviewSection.MEDIA, dto.getSections().get(0).getSection()); + } + + @Test + void acceptsLowercaseSectionAndSeverity() { + String response = """ + {"summary": "S", "sections": [{"section": "content", "severity": "critical", "findings": "x", "suggestions": "y"}]} + """; + + ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response); + + assertEquals(1, dto.getSections().size()); + assertEquals(ProposalReviewSection.CONTENT, dto.getSections().get(0).getSection()); + assertEquals(AiReviewSeverity.CRITICAL, dto.getSections().get(0).getSeverity()); + } + + @Test + void fallsBackToAttentionForInvalidSeverity() { + String response = """ + {"summary": "S", "sections": [{"section": "CONTENT", "severity": "BANANAS", "findings": "x", "suggestions": ""}]} + """; + + ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response); + + assertEquals(AiReviewSeverity.ATTENTION, dto.getSections().get(0).getSeverity()); + } + + @Test + void usesRawResponseAsSummaryWhenNotJson() { + String response = "Sorry, I cannot produce JSON right now."; + + ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response); + + assertEquals(response, dto.getSummary()); + assertTrue(dto.getSections().isEmpty()); + } + + @Test + void handlesNullAndEmptyResponses() { + assertEquals(ProposalAiReviewParser.UNPARSEABLE_SUMMARY, ProposalAiReviewParser.parse(null).getSummary()); + assertEquals(ProposalAiReviewParser.UNPARSEABLE_SUMMARY, ProposalAiReviewParser.parse(" ").getSummary()); + assertTrue(ProposalAiReviewParser.parse(null).getSections().isEmpty()); + } + + @Test + void usesFallbackSummaryWhenSummaryMissing() { + String response = """ + {"sections": [{"section": "CONTENT", "severity": "OK", "findings": "fine", "suggestions": ""}]} + """; + + ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response); + + assertEquals(ProposalAiReviewParser.FALLBACK_SUMMARY, dto.getSummary()); + assertEquals(1, dto.getSections().size()); + } + + @Test + void sortsSectionsBySeverityCriticalFirst() { + String response = """ + {"summary": "S", "sections": [ + {"section": "MEDIA", "severity": "OK", "findings": "a", "suggestions": ""}, + {"section": "CONTENT", "severity": "ATTENTION", "findings": "b", "suggestions": ""}, + {"section": "CREDITS", "severity": "CRITICAL", "findings": "c", "suggestions": ""} + ]} + """; + + List sections = ProposalAiReviewParser.parse(response).getSections(); + + assertEquals(AiReviewSeverity.CRITICAL, sections.get(0).getSeverity()); + assertEquals(AiReviewSeverity.ATTENTION, sections.get(1).getSeverity()); + assertEquals(AiReviewSeverity.OK, sections.get(2).getSeverity()); + } + + @Test + void setsSectionLabelFromSectionEnum() { + String response = """ + {"summary": "S", "sections": [{"section": "LEARNING_OUTCOMES", "severity": "OK", "findings": "x", "suggestions": ""}]} + """; + + ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response); + + assertEquals("Learning outcomes", dto.getSections().get(0).getSectionLabel()); + } +} diff --git a/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtilTest.java b/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtilTest.java new file mode 100644 index 00000000..7318018c --- /dev/null +++ b/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtilTest.java @@ -0,0 +1,103 @@ +package modulemanagement.ls1.shared; + +import modulemanagement.ls1.enums.ProposalReviewSection; +import modulemanagement.ls1.models.AiReviewGuideline; +import modulemanagement.ls1.models.ModuleVersion; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ProposalAiReviewPromptUtilTest { + + private ModuleVersion sampleModuleVersion() { + ModuleVersion mv = new ModuleVersion(); + mv.setTitleEng("Advanced Databases"); + mv.setCredits(6); + mv.setContentEng("Query optimization, transactions, distributed storage."); + return mv; + } + + private AiReviewGuideline guideline(ProposalReviewSection section, String title, String instruction) { + AiReviewGuideline g = new AiReviewGuideline(); + g.setSection(section); + g.setTitle(title); + g.setInstruction(instruction); + return g; + } + + @Test + void promptContainsFilledProposalFields() { + String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(sampleModuleVersion(), List.of()); + + assertTrue(prompt.contains("Advanced Databases")); + assertTrue(prompt.contains("6")); + assertTrue(prompt.contains("Query optimization")); + } + + @Test + void promptOmitsEmptyFields() { + ModuleVersion mv = new ModuleVersion(); + mv.setTitleEng("Only Title"); + + String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(mv, List.of()); + + assertTrue(prompt.contains("Only Title")); + assertFalse(prompt.contains("Learning outcomes:\n")); + } + + @Test + void promptUsesBalancedReviewInstruction() { + String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(sampleModuleVersion(), List.of()); + + assertTrue(prompt.contains("authors improving the proposal")); + assertTrue(prompt.contains("reviewers assessing it")); + } + + @Test + void generalGuidelinesAreIncludedInBasePrompt() { + AiReviewGuideline general = guideline(ProposalReviewSection.GENERAL, "Tone", "Use academic English."); + + String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(sampleModuleVersion(), List.of(general)); + + assertTrue(prompt.contains("Tone")); + assertTrue(prompt.contains("Use academic English.")); + } + + @Test + void missingGuidelinesAreMarkedInPrompt() { + String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(sampleModuleVersion(), List.of()); + + assertTrue(prompt.contains("No general guidelines configured")); + } + + @Test + void appendSectionGuidelinesAddsSectionSpecificRules() { + AiReviewGuideline contentRule = guideline(ProposalReviewSection.CONTENT, "Depth", + "Describe at least three thematic blocks."); + String base = "BASE"; + + String result = ProposalAiReviewPromptUtil.appendSectionGuidelines(base, List.of(contentRule)); + + assertTrue(result.startsWith("BASE")); + assertTrue(result.contains("Depth")); + assertTrue(result.contains("three thematic blocks")); + } + + @Test + void appendSectionGuidelinesWithNoRulesReturnsBase() { + String result = ProposalAiReviewPromptUtil.appendSectionGuidelines("BASE", List.of()); + + assertEquals("BASE", result); + } + + @Test + void promptListsAllowedSectionEnumNames() { + String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(sampleModuleVersion(), List.of()); + + assertTrue(prompt.contains("CONTENT")); + assertTrue(prompt.contains("LEARNING_OUTCOMES")); + assertFalse(prompt.contains("Allowed section enum values: GENERAL")); + } +}