44import de .tum .cit .aet .hephaestus .agent .conversation .ConversationSourceLiveness ;
55import de .tum .cit .aet .hephaestus .agent .documentation .DocumentProjection ;
66import de .tum .cit .aet .hephaestus .agent .handler .PracticeDetectionResultParser .ValidatedObservation ;
7+ import de .tum .cit .aet .hephaestus .agent .handler .spi .EvidenceQuoteUnverifiedException ;
78import de .tum .cit .aet .hephaestus .agent .handler .spi .JobDeliveryException ;
89import de .tum .cit .aet .hephaestus .agent .job .AgentJob ;
910import de .tum .cit .aet .hephaestus .agent .runtime .ProvenanceDigest ;
3334import de .tum .cit .aet .hephaestus .practices .observation .PracticeDetectionCompletedEvent ;
3435import java .nio .charset .StandardCharsets ;
3536import java .time .Instant ;
37+ import java .util .ArrayList ;
3638import java .util .HashMap ;
3739import java .util .HashSet ;
38- import java .util .IdentityHashMap ;
3940import java .util .List ;
4041import java .util .Map ;
4142import java .util .Set ;
@@ -144,7 +145,15 @@ public DeliveryResult deliver(AgentJob job, List<ValidatedObservation> validObse
144145 }
145146 Target target = resolveTarget (job , metadata );
146147 Map <String , PracticeRevision > revisionsBySlug = admittedRevisions (job , workspaceId );
147- for (ValidatedObservation observation : validObservations ) {
148+ // A quote that does not verify discredits its own claim, and only EvidenceQuoteUnverifiedException
149+ // means that. Every other refusal here — an unstaged source, a malformed citation, work attributed
150+ // to the wrong person — impugns the run, so it stays fatal.
151+ List <Integer > admittedIndexes = new ArrayList <>(validObservations .size ());
152+ List <ValidatedObservation > admittedObservations = new ArrayList <>(validObservations .size ());
153+ List <String > withheldObservations = new ArrayList <>();
154+ boolean withheldNegative = false ;
155+ for (int submittedIndex = 0 ; submittedIndex < validObservations .size (); submittedIndex ++) {
156+ ValidatedObservation observation = validObservations .get (submittedIndex );
148157 PracticeRevision revision = revisionsBySlug .get (observation .practiceSlug ());
149158 if (revision == null ) {
150159 throw new JobDeliveryException (
@@ -155,7 +164,42 @@ public DeliveryResult deliver(AgentJob job, List<ValidatedObservation> validObse
155164 );
156165 }
157166 enforceAttribution (observation , revision , job );
158- enforceEvidenceBoundary (observation , revision , evidenceBoundary , job );
167+ try {
168+ enforceEvidenceBoundary (observation , revision , evidenceBoundary , job );
169+ admittedIndexes .add (submittedIndex );
170+ admittedObservations .add (observation );
171+ } catch (EvidenceQuoteUnverifiedException ex ) {
172+ withheldNegative |= observation .assessment () == Assessment .BAD ;
173+ withheldObservations .add (observation .practiceSlug () + ": " + ex .getMessage ());
174+ }
175+ }
176+ if (!withheldObservations .isEmpty ()) {
177+ // Per claim, because a model that cannot quote its own evidence is a defect an otherwise
178+ // successful delivery would hide.
179+ log .warn (
180+ "Withheld {} of {} observation(s) whose quoted evidence did not verify, delivering the rest: jobId={} withheld={}" ,
181+ withheldObservations .size (),
182+ validObservations .size (),
183+ job .getId (),
184+ withheldObservations
185+ );
186+ // Withholding the only fault leaves an all-clear standing over a defect the model did find,
187+ // which is a different statement to the reader than an incomplete review.
188+ if (withheldNegative && admittedObservations .stream ().noneMatch (o -> o .assessment () == Assessment .BAD )) {
189+ log .error (
190+ "Withheld every negative observation; the remaining claims read as an all-clear: jobId={}" ,
191+ job .getId ()
192+ );
193+ }
194+ }
195+ // Only when there was something to admit: a review that found nothing still publishes its zero.
196+ if (admittedObservations .isEmpty () && !validObservations .isEmpty ()) {
197+ throw new JobDeliveryException (
198+ "No observation survived the evidence check, so there is nothing to deliver: jobId=" +
199+ job .getId () +
200+ ", withheld=" +
201+ withheldObservations
202+ );
159203 }
160204
161205 ObservationOrigin origin = originOf (metadata );
@@ -171,20 +215,22 @@ public DeliveryResult deliver(AgentJob job, List<ValidatedObservation> validObse
171215 boolean hasNegative = false ;
172216 Instant observedAt = Instant .now ();
173217
174- // Keyed by observation identity because equal observations still represent distinct occurrences .
175- Map <ValidatedObservation , ObservationKeys > observationKeys = new IdentityHashMap <>();
218+ // Carries the keys each observation was persisted under .
219+ List <ValidatedObservation > deliveredObservations = new ArrayList <>(admittedObservations . size () );
176220
177- for (int i = 0 ; i < validObservations .size (); i ++) {
178- ValidatedObservation observation = validObservations .get (i );
221+ for (int i = 0 ; i < admittedObservations .size (); i ++) {
222+ ValidatedObservation observation = admittedObservations .get (i );
179223
180224 PracticeRevision revision = revisionsBySlug .get (observation .practiceSlug ());
181225 Practice practice = revision .getPractice ();
182226
183- // Includes the index so distinct observations for the same practice on one artifact don't collide.
227+ // The position the observation was SUBMITTED at, not its position among those admitted: this key
228+ // is a retry's dedup grain, so a claim withheld on one attempt and not the next must not renumber
229+ // the claims after it into keys that miss what is already stored.
184230 String occurrenceKey =
185231 observation .practiceSlug () +
186232 ":" +
187- i +
233+ admittedIndexes . get ( i ) +
188234 ":" +
189235 artifactKind .value () +
190236 ":" +
@@ -211,7 +257,7 @@ public DeliveryResult deliver(AgentJob job, List<ValidatedObservation> validObse
211257 aboutUserId ,
212258 firstLocationPath (observation .evidence ())
213259 );
214- observationKeys . put (observation , new ObservationKeys (occurrenceKey , recurrenceKey ));
260+ deliveredObservations . add (observation . withKeys ( new ObservationKeys (occurrenceKey , recurrenceKey ) ));
215261
216262 Long practiceRevisionId = revision .getId ();
217263
@@ -274,7 +320,7 @@ public DeliveryResult deliver(AgentJob job, List<ValidatedObservation> validObse
274320 )
275321 );
276322
277- return new DeliveryResult (inserted , discardedDuplicate , hasNegative , observationKeys );
323+ return new DeliveryResult (inserted , discardedDuplicate , hasNegative , deliveredObservations );
278324 }
279325
280326 /**
@@ -419,7 +465,7 @@ private void enforceEvidenceBoundary(
419465 );
420466 String artifactContent = new String (content , StandardCharsets .UTF_8 );
421467 if (!"scm.pull-request.diff" .equals (kind .value ()) && !artifactContent .contains (exactQuote )) {
422- throw new JobDeliveryException (
468+ throw new EvidenceQuoteUnverifiedException (
423469 "Evidence quote does not occur in the cited artifact: path=" +
424470 artifactPath .asText () +
425471 ", jobId=" +
@@ -445,7 +491,7 @@ private void enforceEvidenceBoundary(
445491 exactQuote
446492 ))
447493 ) {
448- throw new JobDeliveryException (
494+ throw new EvidenceQuoteUnverifiedException (
449495 "Evidence quote does not match the cited diff location: path=" +
450496 path .asText () +
451497 ", line=" +
@@ -996,14 +1042,11 @@ static String firstLocationPath(JsonNode evidence) {
9961042 return path != null && path .isString () ? path .asString () : null ;
9971043 }
9981044
999- /**
1000- * @param observationKeys the keys persisted for each observation, by identity, so the caller stamps the same
1001- * keys onto its deliverable observations instead of recomputing them
1002- */
1045+ /** @param delivered what this call persisted, each carrying the keys it was stored under. */
10031046 public record DeliveryResult (
10041047 int inserted ,
10051048 int discardedDuplicate ,
10061049 boolean hasNegative ,
1007- Map <ValidatedObservation , ObservationKeys > observationKeys
1050+ List <ValidatedObservation > delivered
10081051 ) {}
10091052}
0 commit comments