Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4af67a5
chore: initialize branch for CQI configuration feature
Predixx Feb 28, 2026
8eb35d2
feat: add per-exercise CQI weight configuration (#191)
Predixx Feb 28, 2026
99fb50d
chore: update OpenAPI spec and generated client
github-actions[bot] Feb 28, 2026
949474e
fix: replace useEffect+setState with useReducer in CqiWeightsPanel
Predixx Feb 28, 2026
7a44890
fix: add missing Javadoc to fix checkstyle CI failures
Predixx Feb 28, 2026
0af068c
fix: improve error handling and consolidate migration into initial sc…
Predixx Mar 1, 2026
f8a612a
chore: update OpenAPI spec and generated client
github-actions[bot] Mar 1, 2026
b163c7e
fix: add missing Javadoc to calculateFallback to fix checkstyle
Predixx Mar 1, 2026
5f184c5
test: add tests for CQI weight configuration feature
Predixx Mar 1, 2026
19b0471
fix: use per-exercise weights in SIMPLE mode renormalization
Predixx Mar 4, 2026
7f99c1c
merge: integrate main into feature/cqi-configuration
Predixx Mar 4, 2026
ca5ea5c
fix: address PR #192 review feedback
Predixx Mar 4, 2026
02b7291
feat: make CQI weights panel collapsed by default
Predixx Mar 4, 2026
057cf04
fix: center CQI weights header padding when collapsed
Predixx Mar 4, 2026
0d10dda
fix: recalculate CQI from current weights instead of using stale pers…
Predixx Mar 4, 2026
e2ca72c
fix: use recalculated CQI in all response paths
Predixx Mar 5, 2026
7b4de16
style: remove unused ComponentWeightsDTO import
Predixx Mar 5, 2026
3db2d92
code quality improvements
az108 Mar 5, 2026
44bca1b
code quality improvements
az108 Mar 5, 2026
6680294
fix cqi weights error
az108 Mar 5, 2026
48b2523
fix cqiweights adjustment box
az108 Mar 5, 2026
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
56 changes: 56 additions & 0 deletions openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,54 @@ paths:
required: true
responses:
'200': {description: OK}
/api/exercises/{exerciseId}/cqi-weights:
get:
tags: [cqi-weight-resource]
operationId: getWeights
parameters:
- name: exerciseId
in: path
required: true
schema: {type: integer, format: int64}
responses:
'200':
description: OK
content:
application/json:
schema: {$ref: '#/components/schemas/CqiWeightsDTO'}
put:
tags: [cqi-weight-resource]
operationId: saveWeights
parameters:
- name: exerciseId
in: path
required: true
schema: {type: integer, format: int64}
requestBody:
content:
application/json:
schema: {$ref: '#/components/schemas/CqiWeightsDTO'}
required: true
responses:
'200':
description: OK
content:
application/json:
schema: {type: object}
delete:
tags: [cqi-weight-resource]
operationId: resetWeights
parameters:
- name: exerciseId
in: path
required: true
schema: {type: integer, format: int64}
responses:
'200':
description: OK
content:
application/json:
schema: {$ref: '#/components/schemas/CqiWeightsDTO'}
/api/exercises/{exerciseId}/email-mappings:
get:
tags: [email-mapping-resource]
Expand Down Expand Up @@ -580,6 +628,14 @@ components:
locBalance: {type: number, format: double}
ownershipSpread: {type: number, format: double}
temporalSpread: {type: number, format: double}
CqiWeightsDTO:
type: object
properties:
effortBalance: {type: number, format: double}
isDefault: {type: boolean}
locBalance: {type: number, format: double}
ownershipSpread: {type: number, format: double}
temporalSpread: {type: number, format: double}
CreateEmailMappingRequestDTO:
type: object
properties:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,31 @@ public FairnessReportDTO analyzeFairness(TeamRepositoryDTO repositoryDTO) {
* @return fairness report plus token usage
*/
public FairnessReportWithUsageDTO analyzeFairnessWithUsage(TeamRepositoryDTO repositoryDTO) {
return analyzeFairnessWithUsage(repositoryDTO, null);
return analyzeFairnessWithUsage(repositoryDTO, null, null);
}

/**
* Analyses a repository with optional template author exclusion.
* Analyses a repository with optional template author exclusion (default weights).
*
* @param repositoryDTO the repository to analyse
* @param templateAuthorEmail email of the template author to exclude (lowercase), or {@code null}
* @return fairness report plus token usage
*/
public FairnessReportWithUsageDTO analyzeFairnessWithUsage(TeamRepositoryDTO repositoryDTO,
String templateAuthorEmail) {
return analyzeFairnessWithUsage(repositoryDTO, templateAuthorEmail, null);
}

/**
* Analyses a repository with optional template author exclusion.
*
* @param repositoryDTO the repository to analyse
* @param templateAuthorEmail email of the template author to exclude (lowercase), or {@code null}
* @param exerciseId the exercise ID for per-exercise CQI weight resolution
* @return fairness report plus token usage
*/
public FairnessReportWithUsageDTO analyzeFairnessWithUsage(TeamRepositoryDTO repositoryDTO,
String templateAuthorEmail, Long exerciseId) {
String repoPath = repositoryDTO.localPath();
String teamName = repositoryDTO.participation().team().name();
String shortName = repositoryDTO.participation().team().shortName();
Expand Down Expand Up @@ -145,7 +158,7 @@ public FairnessReportWithUsageDTO analyzeFairnessWithUsage(TeamRepositoryDTO rep
.toList();

CQIResultDTO cqiResult = cqiCalculatorService.calculate(
cqiRatedChunks, teamSize, projectStart, projectEnd, filterSummary, teamName, shortName);
cqiRatedChunks, teamSize, projectStart, projectEnd, filterSummary, teamName, shortName, exerciseId);

// 6) Aggregate author stats
Map<Long, AuthorStats> authorStats = aggregateStats(ratedChunks);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package de.tum.cit.aet.analysis.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.UUID;

@Getter
@Setter
@Entity
@NoArgsConstructor
@Table(name = "cqi_weight_configurations")
public class CqiWeightConfiguration {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

@Column(name = "exercise_id", nullable = false, unique = true)
private Long exerciseId;

@Column(name = "effort_weight", nullable = false)
private double effortWeight = 0.55;

@Column(name = "loc_weight", nullable = false)
private double locWeight = 0.25;

@Column(name = "temporal_weight", nullable = false)
private double temporalWeight = 0.05;

@Column(name = "ownership_weight", nullable = false)
private double ownershipWeight = 0.15;

@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;

@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;

public CqiWeightConfiguration(Long exerciseId, double effortWeight, double locWeight, double temporalWeight, double ownershipWeight) {
this.exerciseId = exerciseId;
this.effortWeight = effortWeight;
this.locWeight = locWeight;
this.temporalWeight = temporalWeight;
this.ownershipWeight = ownershipWeight;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}

@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}

@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}

}
36 changes: 36 additions & 0 deletions src/main/java/de/tum/cit/aet/analysis/dto/cqi/CqiWeightsDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package de.tum.cit.aet.analysis.dto.cqi;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

/**
* DTO for CQI weight configuration. Used for both request (save) and response (get/reset).
* When used as a request body, {@code isDefault} can be omitted (defaults to {@code false}).
*
* @param effortBalance weight for effort balance (0-1)
* @param locBalance weight for lines-of-code balance (0-1)
* @param temporalSpread weight for temporal spread (0-1)
* @param ownershipSpread weight for ownership spread (0-1)
* @param isDefault {@code true} when the weights are application defaults (response-only)
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record CqiWeightsDTO(
double effortBalance,
double locBalance,
double temporalSpread,
double ownershipSpread,
@JsonProperty("isDefault") Boolean isDefault
) {
/**
* Canonical constructor that normalizes {@code null} isDefault to {@code false}.
*/
public CqiWeightsDTO(double effortBalance, double locBalance,
double temporalSpread, double ownershipSpread,
Boolean isDefault) {
this.effortBalance = effortBalance;
this.locBalance = locBalance;
this.temporalSpread = temporalSpread;
this.ownershipSpread = ownershipSpread;
this.isDefault = isDefault != null ? isDefault : false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package de.tum.cit.aet.analysis.repository;

import de.tum.cit.aet.analysis.domain.CqiWeightConfiguration;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;
import java.util.UUID;

@Repository
public interface CqiWeightConfigurationRepository extends JpaRepository<CqiWeightConfiguration, UUID> {

Optional<CqiWeightConfiguration> findByExerciseId(Long exerciseId);

void deleteByExerciseId(Long exerciseId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,11 @@ public ClientResponseDTO mapParticipationToClientResponse(TeamParticipation part
s.getLinesDeleted(), s.getLinesChanged()))
.toList();

Double cqi = participation.getCqi();
Boolean isSuspicious = participation.getIsSuspicious() != null ? participation.getIsSuspicious() : false;

AnalysisMode mode = analysisStateService.getStatus(participation.getExerciseId()).getAnalysisMode();
CQIResultDTO cqiDetails = reconstructCqiDetails(participation, mode);
Double cqi = cqiDetails != null ? cqiDetails.cqi() : participation.getCqi();

return new ClientResponseDTO(
tutor != null ? tutor.getName() : "Unassigned",
Expand Down Expand Up @@ -246,25 +246,26 @@ public CQIResultDTO reconstructCqiDetails(TeamParticipation participation, Analy
pairProgrammingStatus,
deserializeDailyDistribution(participation.getCqiDailyDistribution()));

Long exerciseId = participation.getExerciseId();
ComponentWeightsDTO weights;
if (mode == AnalysisMode.FULL) {
weights = cqiCalculatorService.buildWeightsDTO();
weights = cqiCalculatorService.buildWeightsDTO(exerciseId);
} else if (mode == AnalysisMode.SIMPLE) {
weights = cqiCalculatorService.buildRenormalizedWeightsWithoutEffort();
weights = cqiCalculatorService.buildRenormalizedWeightsWithoutEffort(exerciseId);
} else {
boolean hasEffortBalance = participation.getCqiEffortBalance() != null
&& participation.getCqiEffortBalance() > 0;
weights = hasEffortBalance
? cqiCalculatorService.buildWeightsDTO()
: cqiCalculatorService.buildRenormalizedWeightsWithoutEffort();
? cqiCalculatorService.buildWeightsDTO(exerciseId)
: cqiCalculatorService.buildRenormalizedWeightsWithoutEffort(exerciseId);
}

return new CQIResultDTO(
participation.getCqi() != null ? participation.getCqi() : 0.0,
components,
weights,
participation.getCqiBaseScore() != null ? participation.getCqiBaseScore() : 0.0,
null);
double baseScore = components.weightedSum(
weights.effortBalance(), weights.locBalance(),
weights.temporalSpread(), weights.ownershipSpread());
double cqi = Math.max(0, Math.min(100, baseScore));

return new CQIResultDTO(cqi, components, weights, baseScore, null);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import de.tum.cit.aet.analysis.dto.RepositoryAnalysisResultDTO;
import de.tum.cit.aet.analysis.dto.cqi.CQIResultDTO;
import de.tum.cit.aet.analysis.dto.cqi.ComponentScoresDTO;
import de.tum.cit.aet.analysis.dto.cqi.ComponentWeightsDTO;
import de.tum.cit.aet.analysis.dto.cqi.PreFilterResultDTO;
import de.tum.cit.aet.analysis.repository.AnalyzedChunkRepository;
import de.tum.cit.aet.analysis.repository.ExerciseEmailMappingRepository;
Expand Down Expand Up @@ -213,7 +212,7 @@ public ClientResponseDTO saveGitAnalysisResult(TeamRepositoryDTO repo,

CQIResultDTO finalDetails = gitCqiDetails;
if (mode == AnalysisMode.SIMPLE && gitCqiDetails != null) {
finalDetails = cqiCalculatorService.renormalizeWithoutEffort(gitCqiDetails);
finalDetails = cqiCalculatorService.renormalizeWithoutEffort(gitCqiDetails, exerciseId);
}

return new ClientResponseDTO(
Expand Down Expand Up @@ -310,7 +309,7 @@ public ClientResponseWithUsage saveAIAnalysisResultWithUsage(TeamRepositoryDTO r
PreFilterResultDTO filterResult = commitPreFilterService.preFilter(allChunks);
cqiDetails = cqiCalculatorService.calculateFallback(
filterResult.chunksToAnalyze(), students.size(), filterResult.summary(),
team.name(), team.shortName());
team.name(), team.shortName(), exerciseId);
cqi = cqiDetails.cqi();
} catch (Exception e) {
log.warn("Fallback CQI calculation failed for team {}: {}", team.name(), e.getMessage());
Expand Down Expand Up @@ -348,11 +347,12 @@ public ClientResponseWithUsage saveAIAnalysisResultWithUsage(TeamRepositoryDTO r
.toList();

Tutor tutor = teamParticipation.getTutor();
Double finalCqi = teamParticipation.getCqi() != null ? teamParticipation.getCqi() : cqi;
CQIResultDTO finalCqiDetails = queryService.reconstructCqiDetails(teamParticipation, AnalysisMode.FULL);
if (finalCqiDetails == null) {
finalCqiDetails = cqiDetails;
}
Double finalCqi = finalCqiDetails != null ? finalCqiDetails.cqi()
: (teamParticipation.getCqi() != null ? teamParticipation.getCqi() : cqi);

return new ClientResponseWithUsage(
new ClientResponseDTO(
Expand Down Expand Up @@ -436,22 +436,8 @@ public ClientResponseDTO calculateAndPersistSimpleCqi(ParticipationDTO participa
Double cqi = null;
CQIResultDTO simpleCqiDetails = gitCqiDetails;
if (gitCqiDetails != null && gitCqiDetails.components() != null) {
ComponentWeightsDTO weights = cqiCalculatorService.buildWeightsDTO();
double wLoc = weights.locBalance();
double wTemporal = weights.temporalSpread();
double wOwnership = weights.ownershipSpread();
double divisor = wLoc + wTemporal + wOwnership;

if (divisor > 0) {
double locScore = gitCqiDetails.components().locBalance();
double temporalScore = gitCqiDetails.components().temporalSpread();
double ownershipScore = gitCqiDetails.components().ownershipSpread();

double rawCqi = (wLoc * locScore + wTemporal * temporalScore + wOwnership * ownershipScore) / divisor;
cqi = (double) Math.max(0, Math.min(100, Math.round(rawCqi)));
}

simpleCqiDetails = cqiCalculatorService.renormalizeWithoutEffort(gitCqiDetails);
simpleCqiDetails = cqiCalculatorService.renormalizeWithoutEffort(gitCqiDetails, exerciseId);
cqi = simpleCqiDetails.cqi();
}

persistCqiComponents(teamParticipation, gitCqiDetails);
Expand All @@ -467,12 +453,13 @@ public ClientResponseDTO calculateAndPersistSimpleCqi(ParticipationDTO participa

Tutor tutor = teamParticipation.getTutor();
CQIResultDTO finalDetails = simpleCqiDetails != null ? simpleCqiDetails : queryService.reconstructCqiDetails(teamParticipation, AnalysisMode.SIMPLE);
Double finalCqi = finalDetails != null ? finalDetails.cqi() : cqi;

return new ClientResponseDTO(
tutor != null ? tutor.getName() : "Unassigned",
team.id(), participation.id(), team.name(), team.shortName(),
participation.submissionCount(),
studentDtos, cqi, false, TeamAnalysisStatus.DONE,
studentDtos, finalCqi, false, TeamAnalysisStatus.DONE,
finalDetails, null, null, null, null, null, null);
}

Expand Down Expand Up @@ -683,7 +670,7 @@ private CQIResultDTO calculateGitOnlyCqi(TeamRepositoryDTO repo, TeamParticipati
gitComponents.pairProgrammingStatus() != null ? gitComponents.pairProgrammingStatus().name() : null);
teamParticipationRepository.save(teamParticipation);

return CQIResultDTO.gitOnly(cqiCalculatorService.buildWeightsDTO(), gitComponents, filterResult.summary());
return CQIResultDTO.gitOnly(cqiCalculatorService.buildWeightsDTO(null), gitComponents, filterResult.summary());
}
} catch (Exception e) {
log.warn("Failed to calculate git-only metrics for team {}: {}", team.name(), e.getMessage());
Expand All @@ -701,7 +688,7 @@ private Double calculateFallbackCqi(TeamRepositoryDTO repo, TeamDTO team, List<S
PreFilterResultDTO filterResult = commitPreFilterService.preFilter(allChunks);
CQIResultDTO result = cqiCalculatorService.calculateFallback(
filterResult.chunksToAnalyze(), students.size(), filterResult.summary(),
team.name(), team.shortName());
team.name(), team.shortName(), null);
return result.cqi();
} catch (Exception e) {
log.warn("Fallback CQI calculation failed for team {}: {}", team.name(), e.getMessage());
Expand Down
Loading
Loading