Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@
<span jhiTranslate="artemisApp.editor.repoSelect.deleteAssignmentRepo"></span>
</button>
}
<button (click)="checkConsistencies(exercise)" class="btn btn-primary me-2">
<fa-icon [icon]="faCheckDouble" class="me-2" />
<span jhiTranslate="artemisApp.consistencyCheck.button"></span>
</button>
</div>
</div>
<div editorToolbar class="ms-auto d-flex align-items-center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { ProgrammingExerciseInstructorExerciseStatusComponent } from '../../stat
import { NgbDropdown, NgbDropdownButtonItem, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { RepositoryType } from 'app/programming/shared/code-editor/model/code-editor.model';
import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe';
import { FeatureToggle } from 'app/shared/feature-toggle/feature-toggle.service';
import { faCheckDouble } from '@fortawesome/free-solid-svg-icons';

@Component({
selector: 'jhi-code-editor-instructor',
Expand Down Expand Up @@ -52,4 +54,6 @@ export class CodeEditorInstructorAndEditorContainerComponent extends CodeEditorI
faTimesCircle = faTimesCircle;
irisSettings?: IrisSettings;
protected readonly RepositoryType = RepositoryType;
protected readonly FeatureToggle = FeatureToggle;
protected readonly faCheckDouble = faCheckDouble;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { Component, OnDestroy, OnInit, ViewChild, effect, inject, signal } from '@angular/core';
import { CodeEditorContainerComponent } from 'app/programming/manage/code-editor/container/code-editor-container.component';
import { Observable, Subscription, of, throwError } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
Expand All @@ -21,6 +21,13 @@ import { CourseExerciseService } from 'app/exercise/course-exercises/course-exer
import { isExamExercise } from 'app/shared/util/utils';
import { Subject } from 'rxjs';
import { debounceTime, shareReplay } from 'rxjs/operators';
import { ConsistencyCheckComponent } from 'app/programming/manage/consistency-check/consistency-check.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ConsistencyCheckAction } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/consistency-check.action';
import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service';
import { ConsistencyIssue } from 'app/openapi/model/consistencyIssue';
import { ConsistencyCheckService } from 'app/programming/manage/consistency-check/consistency-check.service';

/**
* Enumeration specifying the loading state
*/
Expand Down Expand Up @@ -48,6 +55,10 @@ export abstract class CodeEditorInstructorBaseContainerComponent implements OnIn
private alertService = inject(AlertService);
/** Raw markdown changes from the center editor for debounce logic */
private problemStatementChanges$ = new Subject<string>();
private modalService = inject(NgbModal);
private artemisIntelligenceService = inject(ArtemisIntelligenceService);
private consistencyIssues = signal<ConsistencyIssue[]>([]);
private consistencyCheckService = inject(ConsistencyCheckService);

ButtonSize = ButtonSize;
LOADING_STATE = LOADING_STATE;
Expand Down Expand Up @@ -77,6 +88,17 @@ export abstract class CodeEditorInstructorBaseContainerComponent implements OnIn
// State variables
loadingState = LOADING_STATE.CLEAR;

constructor() {
effect(() => {
const issues = this.consistencyIssues();
for (const issue of issues) {
for (const loc of issue.relatedLocations) {
this.codeEditorContainer.monacoEditor.addCommentBox(loc.endLine, issue.description);
}
}
});
}

protected isCreateAssignmentRepoDisabled: boolean;
/** Debounced tick stream consumed by the sidebar preview */
previewEvents$ = this.problemStatementChanges$.pipe(
Expand Down Expand Up @@ -370,6 +392,31 @@ export abstract class CodeEditorInstructorBaseContainerComponent implements OnIn
});
}

/**
* Opens modal and executes a consistency check for the given programming exercise
* @param exercise the programming exercise to check
*/
checkConsistencies(exercise: ProgrammingExercise) {
this.consistencyCheckService.checkConsistencyForProgrammingExercise(exercise.id!).subscribe({
next: (inconsistencies) => {
if (inconsistencies?.length) {
// only show modal if inconsistencies found
const modalRef = this.modalService.open(ConsistencyCheckComponent, { keyboard: true, size: 'lg' });
modalRef.componentInstance.exercisesToCheck = [exercise];
return;
}

const action = new ConsistencyCheckAction(this.artemisIntelligenceService, exercise.id!, this.codeEditorContainer.monacoEditor.consistencyIssuesInternal);
this.codeEditorContainer.monacoEditor.editor().registerAction(action);
action.executeInCurrentEditor();
},
error: (err) => {
const modalRef = this.modalService.open(ConsistencyCheckComponent, { keyboard: true, size: 'lg' });
modalRef.componentInstance.exercisesToCheck = [exercise];
},
});
}

/**
* Show an error as an alert in the top of the editor html.
* Used by other components to display errors.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-int
import { ProfileService } from 'app/core/layouts/profiles/shared/profile.service';
import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service';
import { ActivatedRoute } from '@angular/router';
import { ConsistencyCheckAction } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/consistency-check.action';
import { Annotation } from 'app/programming/shared/code-editor/monaco/code-editor-monaco.component';
import { RewriteResult } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-result';

Expand Down Expand Up @@ -89,9 +88,6 @@ export class ProgrammingExerciseEditableInstructionComponent implements AfterVie
signal<RewriteResult>({ result: '', inconsistencies: undefined, suggestions: undefined, improvement: undefined }),
),
);
if (this.exerciseId) {
actions.push(new ConsistencyCheckAction(this.artemisIntelligenceService, this.exerciseId, this.renderedConsistencyCheckResultMarkdown));
}
}
return actions;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export class CodeEditorInstructionsComponent implements AfterViewInit {
disableCollapse = input(false);
// different translation for problem statement editor and preview
isEditor = input(false);

/** Resizable constants **/
initialInstructionsWidth: number;
minInstructionsWidth: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,12 @@
.monaco-diff-line-highlight {
background-color: var(--monaco-editor-diff-line-highlight);
}

.monaco-comment-widget {
padding: 6px 8px;
border-radius: 4px;
background: var(--bs-gray-800);
color: white;
font-size: 12px;
margin: 4px 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ import { TranslateDirective } from 'app/shared/language/translate.directive';
import { CodeEditorRepositoryFileService, ConnectionError } from 'app/programming/shared/code-editor/services/code-editor-repository.service';
import { CommitState, CreateFileChange, DeleteFileChange, EditorState, FileChange, FileType, RenameFileChange } from '../model/code-editor.model';
import { CodeEditorFileService } from 'app/programming/shared/code-editor/services/code-editor-file.service';
import { ConsistencyIssue } from 'app/openapi/model/consistencyIssue';

type FileSession = { [fileName: string]: { code: string; cursor: EditorPosition; scrollTop: number; loadingError: boolean } };
type FeedbackWithLineAndReference = Feedback & { line: number; reference: string };
type InlineConsistencyIssue = { line: number; text: string };
export type Annotation = { fileName: string; row: number; column: number; text: string; type: string; timestamp: number; hash?: string };
@Component({
selector: 'jhi-code-editor-monaco',
Expand Down Expand Up @@ -112,6 +114,26 @@ export class CodeEditorMonacoComponent implements OnChanges {
this.filterFeedbackForSelectedFile(this.feedbackSuggestionsInternal()).map((f) => this.attachLineAndReferenceToFeedback(f)),
);

readonly consistencyIssuesInternal = signal<ConsistencyIssue[]>([]);

readonly consistencyIssuesForSelectedFile = computed<InlineConsistencyIssue[]>(() => {
if (!this.selectedFile()) {
return [];
}

const result = [];

for (const issue of this.consistencyIssuesInternal()) {
for (const loc of issue.relatedLocations) {
if (loc.filePath === this.selectedFile()) {
result.push({ line: loc.endLine, text: issue.description });
}
}
}

return result;
});

/**
* Attaches the line number & reference to a feedback item, or -1 if no line is available. This is used to disambiguate feedback items in the template, avoiding warnings.
* @param feedback The feedback item to attach the line to.
Expand All @@ -136,6 +158,11 @@ export class CodeEditorMonacoComponent implements OnChanges {
const annotations = this.buildAnnotations();
untracked(() => this.setBuildAnnotations(annotations));
});

effect(() => {
this.consistencyIssuesForSelectedFile();
this.renderFeedbackWidgets();
});
}

async ngOnChanges(changes: SimpleChanges): Promise<void> {
Expand Down Expand Up @@ -354,6 +381,11 @@ export class CodeEditorMonacoComponent implements OnChanges {
if (lineOfWidgetToFocus !== undefined) {
this.getInlineFeedbackNode(lineOfWidgetToFocus)?.querySelector<HTMLTextAreaElement>('#feedback-textarea')?.focus();
}

// Readd inconsistency issue comments, because all widgets got removed
for (const issue of this.consistencyIssuesForSelectedFile()) {
this.addCommentBox(issue.line, issue.text);
}
}, 0);
}

Expand Down Expand Up @@ -400,6 +432,19 @@ export class CodeEditorMonacoComponent implements OnChanges {
return feedbacks.filter((feedback) => feedback.reference && Feedback.getReferenceFilePath(feedback) === this.selectedFile());
}

addCommentBox(lineNumber: number, text: string) {
// Monaco is 1-based
const oneBasedLine = lineNumber + 1;

const node = document.createElement('div');
node.className = 'my-comment-widget';
node.innerText = text;

// Place box beneath the line
this.editor().addLineWidget(oneBasedLine, `comment-${oneBasedLine}`, node);
this.highlightLines(oneBasedLine, oneBasedLine);
}

/**
* Updates the state of the fileSession based on a change made to the files themselves (not the content).
* - If a file was renamed, references to it are updated to use its new name.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { TextEditorAction } from 'app/shared/monaco-editor/model/actions/text-ed
import { TextEditor } from 'app/shared/monaco-editor/model/actions/adapter/text-editor.interface';
import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service';
import { WritableSignal } from '@angular/core';
import { ConsistencyIssue } from 'app/openapi/model/consistencyIssue';

/**
* Artemis Intelligence action for consistency checking exercises
Expand All @@ -14,7 +15,7 @@ export class ConsistencyCheckAction extends TextEditorAction {
constructor(
private readonly artemisIntelligenceService: ArtemisIntelligenceService,
private readonly exerciseId: number,
private readonly resultSignal: WritableSignal<string>,
private readonly resultSignal: WritableSignal<ConsistencyIssue[]>,
) {
super(ConsistencyCheckAction.ID, 'artemisApp.markdownEditor.artemisIntelligence.commands.consistencyCheck');
}
Expand All @@ -28,6 +29,6 @@ export class ConsistencyCheckAction extends TextEditorAction {
* @param resultSignal The signal to write the result of the consistency check to.
*/
run(editor: TextEditor): void {
this.consistencyCheck(editor, this.artemisIntelligenceService, this.exerciseId, this.resultSignal);
this.consistencyCheck(this.artemisIntelligenceService, this.exerciseId, this.resultSignal);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ import { TextEditorKeybinding } from 'app/shared/monaco-editor/model/actions/ada
import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant';
import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service';
import { WritableSignal } from '@angular/core';
import { htmlForMarkdown } from 'app/shared/util/markdown.conversion.util';
import { ConsistencyCheckResponse } from 'app/openapi/model/consistencyCheckResponse';
import { ConsistencyIssue } from 'app/openapi/model/consistencyIssue';
import { ArtifactLocation } from 'app/openapi/model/artifactLocation';
import { RewriteResult } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-result';

export abstract class TextEditorAction implements Disposable {
Expand Down Expand Up @@ -333,118 +331,15 @@ export abstract class TextEditorAction implements Disposable {
/**
* Runs the consistency check on the exercise.
*
* @param editor The editor for which the consistency check should be run (only used for checking non-empty text).
* @param artemisIntelligence The service to use for consistency checking.
* @param exerciseId The id of the exercise to check.
* @param resultSignal The signal to write the result of the consistency check to.
*/
consistencyCheck(editor: TextEditor, artemisIntelligence: ArtemisIntelligenceService, exerciseId: number, resultSignal: WritableSignal<string>): void {
const text = editor.getFullText();
if (text) {
artemisIntelligence.consistencyCheck(exerciseId).subscribe({
next: (response: ConsistencyCheckResponse) => {
const markdownResult = this.formatConsistencyCheckResults(response);
resultSignal.set(htmlForMarkdown(markdownResult));
},
});
}
}

/**
* Formats consistency check results into well-structured markdown for instructor review.
*/
private formatConsistencyCheckResults(response: ConsistencyCheckResponse): string {
const issues = response.issues ?? [];
let md = '';

if (!issues.length) {
return md + `No consistency issues found.\n`;
}

// Severity summary
const severityCount = this.getSeverityCount(issues);
const sevBadge = (sev: string | undefined) => {
switch (sev) {
case ConsistencyIssue.SeverityEnum.High:
return 'HIGH';
case ConsistencyIssue.SeverityEnum.Medium:
return 'MEDIUM';
case ConsistencyIssue.SeverityEnum.Low:
return 'LOW';
default:
return sev ?? 'UNKNOWN';
}
};
const summaryParts: string[] = [];
if (severityCount[ConsistencyIssue.SeverityEnum.High]) summaryParts.push(`${severityCount[ConsistencyIssue.SeverityEnum.High]} HIGH`);
if (severityCount[ConsistencyIssue.SeverityEnum.Medium]) summaryParts.push(`${severityCount[ConsistencyIssue.SeverityEnum.Medium]} MEDIUM`);
if (severityCount[ConsistencyIssue.SeverityEnum.Low]) summaryParts.push(`${severityCount[ConsistencyIssue.SeverityEnum.Low]} LOW`);
md += `**${issues.length} issue${issues.length === 1 ? '' : 's'}** (${summaryParts.join(', ')})\n\n`;

issues.forEach((issue, index) => {
const number = index + 1;
const categoryRaw = issue.category || 'GENERAL';
const category = this.humanizeCategory(categoryRaw);
md += `**${number}. [${sevBadge(issue.severity)}] ${category}**\n\n`;
md += `${issue.description}\n\n`;
if (issue.suggestedFix) {
md += `**Suggested fix:** ${issue.suggestedFix}\n\n`;
}
// Full location listing (no collapsing)
if (issue.relatedLocations && issue.relatedLocations.length > 0) {
md += `**Locations:**\n`;
issue.relatedLocations.forEach((loc) => {
const typeLabel = this.formatArtifactType(loc.type as ArtifactLocation.TypeEnum);
const file = loc.filePath ?? '';
let linePart = '';
if (loc.startLine && loc.endLine) {
linePart = loc.startLine === loc.endLine ? `:L${loc.startLine}` : `:L${loc.startLine}-${loc.endLine}`;
} else if (loc.startLine) {
linePart = `:L${loc.startLine}`;
}
md += `- ${typeLabel}${file ? `: ${file}` : ''}${linePart}\n`;
});
md += `\n`;
}
});

return md;
}

/**
* Convert an ENUM_STYLE category (e.g. IDENTIFIER_NAMING_INCONSISTENCY) into Title Case (e.g. Identifier Naming Inconsistency)
*/
private humanizeCategory(category: string): string {
return category
.split('_')
.filter((p) => p.length > 0)
.map((p) => p.charAt(0) + p.slice(1).toLowerCase())
.join(' ');
}

private getSeverityCount(issues: ConsistencyIssue[]): Record<string, number> {
return issues.reduce(
(count, issue) => {
const severityKey = issue.severity;
count[severityKey] = (count[severityKey] || 0) + 1;
return count;
consistencyCheck(artemisIntelligence: ArtemisIntelligenceService, exerciseId: number, resultSignal: WritableSignal<ConsistencyIssue[]>): void {
artemisIntelligence.consistencyCheck(exerciseId).subscribe({
next: (response: ConsistencyCheckResponse) => {
resultSignal.set(response.issues ?? []);
},
{} as Record<string, number>,
);
}

private formatArtifactType(type: ArtifactLocation.TypeEnum): string {
switch (type) {
case ArtifactLocation.TypeEnum.ProblemStatement:
return 'Problem Statement';
case ArtifactLocation.TypeEnum.TemplateRepository:
return 'Template';
case ArtifactLocation.TypeEnum.SolutionRepository:
return 'Solution';
case ArtifactLocation.TypeEnum.TestsRepository:
return 'Tests';
default:
return type;
}
});
}
}
Loading