Skip to content

Commit a3ddd7c

Browse files
Valentin Grünerclaude
authored andcommitted
fix(server): keep a comment that came without a resolution
The explanation subquery looked for the newest row carrying a resolution, so a comment written alongside usefulness alone was stored and then never read back — and a later resolution overwrote the reading with its own empty comment. It now looks for the newest row carrying an explanation, which is the column it was always after. Both readers had the same filter. Area trends pooled their practices with 1/Sum(w/v), which is the variance of an inverse-variance mean only when every weight is 1. A weight of 2 halved the variance it reported and turned an uncertain area into a confident verdict on the strength of an admin setting. The numerator now carries Sum(w^2/v) so a uniform rescaling leaves the direction alone. GraphQlOperationDocumentValidationTest scoped its GitHub carve-out by matching the English wording of the validation message. graphql-java localises those, so the build went red on a German workstation while CI stayed green. It now reads the two field paths, which are part of the query rather than of the translation. Also folds the shared query-filter parsing into practices/web and renames the branch changelog so its timestamp sorts after the released ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 19213f4 commit a3ddd7c

19 files changed

Lines changed: 193 additions & 128 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"hephaestus": patch
3+
---
4+
5+
A comment written alongside "not helpful" is no longer lost. Explaining why a piece of feedback missed the mark, without also saying whether you addressed or disputed it, stored the text and then never showed it again — the next answer overwrote the reading with its own empty comment. The words now stay with the answer they came with.
6+
7+
Areas whose practices carry different weights also stop overstating how sure they are: a weight above one made an area's trend read as a confident improvement or decline where the evidence only supported "uncertain".

server/openapi.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11382,7 +11382,6 @@ components:
1138211382
type: string
1138311383
required:
1138411384
- id
11385-
- title
1138611385
- type
1138711386
PracticeAreaReviewFinding:
1138811387
type: object

server/src/main/java/de/tum/cit/aet/hephaestus/practices/areadetail/PracticeAreaReviewHistoryFilterParams.java

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,21 @@
22

33
import de.tum.cit.aet.hephaestus.integration.core.signal.ArtifactKind;
44
import de.tum.cit.aet.hephaestus.practices.model.Severity;
5+
import de.tum.cit.aet.hephaestus.practices.web.QueryFilterSupport;
56
import io.swagger.v3.oas.annotations.Parameter;
67
import java.util.List;
7-
import java.util.Objects;
88
import org.jspecify.annotations.Nullable;
9-
import org.springframework.data.domain.PageRequest;
109
import org.springframework.data.domain.Pageable;
11-
import org.springframework.http.HttpStatus;
1210
import org.springframework.web.bind.annotation.RequestParam;
13-
import org.springframework.web.server.ResponseStatusException;
1411

1512
/**
1613
* Query parameters for the learner-facing practice-area review history.
1714
*
18-
* <p>Optional components are nullable wrappers defaulted in the compact constructor: a
19-
* {@code defaultValue} on {@code @RequestParam} does not reach a record bound as a
20-
* {@code @ParameterObject}, so a primitive component would receive {@code null} and answer 400 to a
21-
* request that named no filter at all.
15+
* <p>Every optional component is a nullable wrapper, and {@link QueryFilterSupport} states why, along
16+
* with how a raw artifact kind and a raw page become the values this surface reads.
2217
*/
2318
public record PracticeAreaReviewHistoryFilterParams(
2419
@RequestParam(required = false) @Nullable String practiceSlug,
25-
/**
26-
* Bare strings, not {@link ArtifactKind}s: springdoc publishes a typed parameter as
27-
* {@code artifactKinds.value}, so a generated client would send a query key no caller writes. The
28-
* grammar is enforced in {@link #kinds()}, where a malformed value becomes a 400.
29-
*/
3020
@Parameter(description = "Only reviews of these artifact kinds, e.g. scm.pull_request (repeatable)")
3121
@RequestParam(required = false)
3222
@Nullable
@@ -38,31 +28,16 @@ public record PracticeAreaReviewHistoryFilterParams(
3828
Integer page,
3929
@Parameter(description = "Page size, clamped to 1..50") @RequestParam(required = false) @Nullable Integer size
4030
) {
41-
public PracticeAreaReviewHistoryFilterParams {
42-
page = page == null || page < 0 ? 0 : page;
43-
size = size == null ? 10 : Math.clamp(size, 1, 50);
44-
}
31+
private static final int DEFAULT_PAGE_SIZE = 10;
32+
private static final int MAX_PAGE_SIZE = 50;
4533

46-
/**
47-
* The page to read, already normalised.
48-
*
49-
* <p>The compact constructor above defaults a missing or negative page to the first one and clamps the
50-
* size, so neither component is null by the time anything reads it. Returning the {@link Pageable} here
51-
* rather than the two numbers keeps that normalisation in one place and out of the controller.
52-
*/
34+
/** The page to read, already normalised. */
5335
public Pageable pageable() {
54-
return PageRequest.of(Objects.requireNonNull(page), Objects.requireNonNull(size));
36+
return QueryFilterSupport.pageable(page, size, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
5537
}
5638

5739
/** The requested artifact kinds, parsed; {@code null} means "every kind". */
5840
public @Nullable List<ArtifactKind> kinds() {
59-
if (artifactKinds == null) {
60-
return null;
61-
}
62-
try {
63-
return artifactKinds.stream().map(ArtifactKind::of).toList();
64-
} catch (IllegalArgumentException invalid) {
65-
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, invalid.getMessage(), invalid);
66-
}
41+
return QueryFilterSupport.artifactKinds(artifactKinds);
6742
}
6843
}

server/src/main/java/de/tum/cit/aet/hephaestus/practices/areadetail/PracticeAreaReviewHistoryService.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ public Page<PracticeAreaReviewMomentDTO> list(
8787
pageable
8888
);
8989
if (runs.isEmpty()) {
90-
return Page.empty(pageable);
90+
// Empty content, but the count the database made: a page read past the last one still has to say
91+
// how many runs there are, or a stale deep link reads as "this area was never reviewed".
92+
return new PageImpl<>(List.of(), pageable, runs.getTotalElements());
9193
}
9294

9395
List<UUID> jobIds = runs.stream().map(ReviewHistoryRunRow::getJobId).toList();
@@ -170,7 +172,7 @@ private PracticeAreaReviewMomentDTO toMoment(
170172
PracticeAreaReviewArtifactDTO artifact =
171173
target == null
172174
? PracticeAreaReviewArtifactDTO.fallback(first.getArtifactKind(), first.getArtifactId())
173-
: PracticeAreaReviewArtifactDTO.from(target, first.getArtifactKind(), first.getArtifactId());
175+
: PracticeAreaReviewArtifactDTO.from(target, first.getArtifactId());
174176
List<PracticeAreaReviewFindingDTO> findings = observations
175177
.stream()
176178
.map(observation -> {

server/src/main/java/de/tum/cit/aet/hephaestus/practices/areadetail/dto/PracticeAreaReviewArtifactDTO.java

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import de.tum.cit.aet.hephaestus.integration.core.signal.ArtifactKind;
44
import de.tum.cit.aet.hephaestus.integration.core.spi.IntegrationKind;
5-
import de.tum.cit.aet.hephaestus.practices.model.ArtifactKinds;
65
import de.tum.cit.aet.hephaestus.practices.spi.ReviewRunTargetLookup.Target;
76
import io.swagger.v3.oas.annotations.media.Schema;
87
import org.jspecify.annotations.NonNull;
@@ -14,12 +13,12 @@ public record PracticeAreaReviewArtifactDTO(
1413
@NonNull Long id,
1514
@Nullable IntegrationKind provider,
1615
@Nullable Integer number,
17-
@NonNull String title,
16+
@Nullable String title,
1817
@Nullable String repositoryName,
1918
@Nullable String channelName,
2019
@Nullable String url
2120
) {
22-
public static PracticeAreaReviewArtifactDTO from(Target target, ArtifactKind fallbackType, Long fallbackId) {
21+
public static PracticeAreaReviewArtifactDTO from(Target target, Long fallbackId) {
2322
return new PracticeAreaReviewArtifactDTO(
2423
target.type(),
2524
target.id() == null ? fallbackId : target.id(),
@@ -32,25 +31,12 @@ public static PracticeAreaReviewArtifactDTO from(Target target, ArtifactKind fal
3231
);
3332
}
3433

35-
public static PracticeAreaReviewArtifactDTO fallback(ArtifactKind type, Long id) {
36-
return new PracticeAreaReviewArtifactDTO(type, id, null, null, fallbackTitle(type), null, null, null);
37-
}
38-
3934
/**
40-
* ArtifactKind is an open {@code <domain>.<kind>} vocabulary rather than an enum, so this maps the kinds
41-
* that reach the review history and falls back to the raw kind for anything a later module introduces —
42-
* an unnamed kind must still render, not crash the page.
35+
* The artifact of a run whose job row is gone. Only its identity survives, so every descriptive field is
36+
* absent rather than invented: a null title says the title is unknown, where "Pull request" would claim
37+
* that is what the work is called.
4338
*/
44-
private static String fallbackTitle(ArtifactKind type) {
45-
if (ArtifactKinds.PULL_REQUEST.equals(type)) {
46-
return "Pull request";
47-
}
48-
if (ArtifactKinds.ISSUE.equals(type)) {
49-
return "Issue";
50-
}
51-
if (ArtifactKinds.CONVERSATION_THREAD.equals(type)) {
52-
return "Conversation";
53-
}
54-
return type.value();
39+
public static PracticeAreaReviewArtifactDTO fallback(ArtifactKind type, Long id) {
40+
return new PracticeAreaReviewArtifactDTO(type, id, null, null, null, null, null, null);
5541
}
5642
}

server/src/main/java/de/tum/cit/aet/hephaestus/practices/feedback/FeedbackObservationRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ LEFT JOIN LATERAL (
162162
ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS action,
163163
(SELECT r.explanation FROM reaction r
164164
WHERE r.feedback_id = f.id AND r.reactor_user_id = :recipientUserId
165-
AND r.action IS NOT NULL
165+
AND r.explanation IS NOT NULL
166166
""" +
167167
ReactionRepository.STILL_SPEAKS +
168168
"""

server/src/main/java/de/tum/cit/aet/hephaestus/practices/observation/ObservationFeedFilterParams.java

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,23 @@
33
import de.tum.cit.aet.hephaestus.integration.core.signal.ArtifactKind;
44
import de.tum.cit.aet.hephaestus.practices.model.Presence;
55
import de.tum.cit.aet.hephaestus.practices.model.Severity;
6+
import de.tum.cit.aet.hephaestus.practices.web.QueryFilterSupport;
67
import io.swagger.v3.oas.annotations.Parameter;
78
import java.util.List;
89
import java.util.Objects;
910
import org.jspecify.annotations.Nullable;
1011
import org.springframework.data.domain.PageRequest;
1112
import org.springframework.data.domain.Pageable;
1213
import org.springframework.data.domain.Sort;
13-
import org.springframework.http.HttpStatus;
1414
import org.springframework.web.bind.annotation.RequestParam;
15-
import org.springframework.web.server.ResponseStatusException;
1615

1716
/**
1817
* Query parameters for the developer observation feed. Bound with {@code @ParameterObject}, so the wire
1918
* format stays one flat query string — this only changes the Java signature, not the HTTP contract.
2019
*
21-
* <p>Every optional component is a nullable wrapper, and the compact constructor supplies the defaults.
22-
* A {@code defaultValue} on {@code @RequestParam} does not reach a record bound this way: the binder
23-
* constructs the record and hands a primitive component {@code null}, which fails conversion and answers
24-
* 400 to a request that named no filter at all.
20+
* <p>Every optional component is a nullable wrapper; the compact constructor supplies the ordering
21+
* defaults and {@link QueryFilterSupport} the paging ones, and states why neither can come from a
22+
* {@code defaultValue} on {@code @RequestParam}.
2523
*/
2624
public record ObservationFeedFilterParams(
2725
@Parameter(description = "Filter by practice slug") @RequestParam(required = false) @Nullable String practiceSlug,
@@ -31,9 +29,8 @@ public record ObservationFeedFilterParams(
3129
String areaSlug,
3230
@Parameter(description = "Filter by presence") @RequestParam(required = false) @Nullable Presence presence,
3331
/**
34-
* Bare strings, not {@link ArtifactKind}s: springdoc walks into the record and publishes a typed
35-
* parameter as {@code artifactKinds.value}, so a generated client would send a query key no caller
36-
* writes. The grammar is enforced in {@link #toQuery()} instead, where a malformed value becomes a 400.
32+
* Bare strings, not {@link ArtifactKind}s — {@link QueryFilterSupport#artifactKind} has the reason,
33+
* and parses them in {@link #toQuery()}, where a malformed value becomes a 400.
3734
*/
3835
@Parameter(description = "Only observations on these artifact kinds, e.g. scm.pull_request (repeatable)")
3936
@RequestParam(required = false)
@@ -59,25 +56,23 @@ public record ObservationFeedFilterParams(
5956
Integer page,
6057
@Parameter(description = "Page size, clamped to 1..100") @RequestParam(required = false) @Nullable Integer size
6158
) {
59+
private static final int DEFAULT_PAGE_SIZE = 20;
60+
private static final int MAX_PAGE_SIZE = 100;
61+
6262
public ObservationFeedFilterParams {
6363
displayableOnly = displayableOnly != null && displayableOnly;
6464
sort = sort == null ? ObservationService.ObservationSort.DATE : sort;
6565
direction = direction == null ? Sort.Direction.DESC : direction;
66-
// Clamped, not rejected: a feed is a reading surface, and answering 400 to "?size=999" tells a
67-
// reader nothing they can act on while hiding data they are allowed to see.
68-
page = page == null || page < 0 ? 0 : page;
69-
size = size == null ? 20 : Math.clamp(size, 1, 100);
7066
}
7167

7268
/**
7369
* The page to read, already normalised and sorted.
7470
*
75-
* <p>The compact constructor above defaults every paging component, so none is null by the time
76-
* anything reads it. The severity query carries its own ORDER BY, so it must not also receive a sort:
77-
* adding one would have the database order by two different keys.
71+
* <p>The severity query carries its own ORDER BY, so it must not also receive a sort: adding one
72+
* would have the database order by two different keys.
7873
*/
7974
public Pageable pageable() {
80-
PageRequest page = PageRequest.of(Objects.requireNonNull(this.page), Objects.requireNonNull(size));
75+
Pageable page = QueryFilterSupport.pageable(this.page, size, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
8176
return sort() == ObservationService.ObservationSort.SEVERITY
8277
? page
8378
: PageRequest.of(
@@ -93,22 +88,11 @@ public ObservationFeedQuery toQuery() {
9388
practiceSlug,
9489
areaSlug,
9590
presence,
96-
parseArtifactKinds(),
91+
QueryFilterSupport.artifactKinds(artifactKinds),
9792
severities,
9893
Objects.requireNonNull(displayableOnly),
9994
Objects.requireNonNull(sort),
10095
direction == Sort.Direction.DESC
10196
);
10297
}
103-
104-
private @Nullable List<ArtifactKind> parseArtifactKinds() {
105-
if (artifactKinds == null) {
106-
return null;
107-
}
108-
try {
109-
return artifactKinds.stream().map(ArtifactKind::of).toList();
110-
} catch (IllegalArgumentException invalid) {
111-
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, invalid.getMessage(), invalid);
112-
}
113-
}
11498
}

server/src/main/java/de/tum/cit/aet/hephaestus/practices/observation/ObservationRepository.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,14 @@ Page<ReviewHistoryRunRow> findReviewHistoryRuns(
483483
Pageable pageable
484484
);
485485

486+
/**
487+
* The findings of the runs {@link #findReviewHistoryRuns} returned.
488+
*
489+
* <p>Fetches both revisions because every row is handed straight to {@code ObservationVisibilityPolicy},
490+
* which reads the evaluated revision and the practice's current one to decide whether the claim still
491+
* speaks for the practice — lazily, that is one round trip per practice and revision on the page.
492+
*/
493+
@EntityGraph(attributePaths = { "practice.currentRevision", "practiceRevision" })
486494
@Query(
487495
"""
488496
SELECT o FROM Observation o

server/src/main/java/de/tum/cit/aet/hephaestus/practices/observation/reaction/ReactionRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ AND NOT EXISTS (
7474
ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS "resolution",
7575
(SELECT r.explanation FROM reaction r
7676
WHERE r.feedback_id = :feedbackId AND r.reactor_user_id = :reactorUserId
77-
AND r.action IS NOT NULL
77+
AND r.explanation IS NOT NULL
7878
""" +
7979
STILL_SPEAKS +
8080
"""

server/src/main/java/de/tum/cit/aet/hephaestus/practices/observation/trend/AreaTrendAggregator.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,22 @@ private record Pooled(double mean, double variance) {}
100100
private static Pooled pool(List<PracticeTrend> comparable, Map<String, Double> weights) {
101101
double weightedMean = 0.0;
102102
double totalPrecision = 0.0;
103+
double varianceNumerator = 0.0;
103104
for (PracticeTrend trend : comparable) {
104105
// `comparable` was filtered on difference() != null above; the nullness analysis
105106
// does not carry that across the stream boundary.
106107
BetaPosterior.Difference difference = Objects.requireNonNull(trend.difference());
107-
double precision = weightFor(trend.slug(), weights) / difference.variance();
108+
double weight = weightFor(trend.slug(), weights);
109+
double precision = weight / difference.variance();
108110
weightedMean += precision * difference.mean();
109111
totalPrecision += precision;
112+
// Var(Σ pᵢmᵢ / Σ pᵢ) = Σ(pᵢ²·vᵢ) / (Σ pᵢ)², and pᵢ²·vᵢ collapses to wᵢ²/vᵢ = wᵢ·pᵢ.
113+
// Only when every weight is 1 does that reduce to the familiar 1/Σ pᵢ, so the
114+
// numerator has to be carried separately — a weight of 2 would otherwise report
115+
// half the variance it has and turn an UNCERTAIN area into a confident verdict.
116+
varianceNumerator += weight * precision;
110117
}
111-
return new Pooled(weightedMean / totalPrecision, 1.0 / totalPrecision);
118+
return new Pooled(weightedMean / totalPrecision, varianceNumerator / (totalPrecision * totalPrecision));
112119
}
113120

114121
/**

0 commit comments

Comments
 (0)