Skip to content

feat(application-server): finding feedback entity + API #898

@FelixTJDietrich

Description

@FelixTJDietrich

Context

The new practice_finding entity is @Immutable (append-only, Hibernate enforced). Contributors cannot mark findings as applied, dismissed, or disputed. The legacy system had userState (FIXED/WONT_FIX/WRONG) on PullRequestBadPractice plus a separate BadPracticeFeedback table — neither is connected to the new practice model.

Without feedback, we cannot answer RQ2 ("did users act on guidance?") or RQ4 ("do users find the guidance helpful?"). The dashboard (#897 v2) needs this for engagement metrics.

Audit Findings (applied)

  1. Must be @immutable — For research data integrity, feedback must be append-only. A contributor submitting a second reaction creates a new row, not an upsert. This preserves the temporal record of when a contributor first saw and then changed their mind about a finding. The original issue proposed UNIQUE (finding_id, user_id) with upsert — this is wrong for research.
  2. 3 actions, not 4 — DISMISSED vs WONT_FIX is ambiguous. User testing will show confusion. Start with: APPLIED ("I fixed this"), DISPUTED ("The AI is wrong"), NOT_APPLICABLE ("Not relevant to my case" — aligns with existing Verdict.NOT_APPLICABLE enum). Add WONT_FIX only if user testing reveals a gap.
  3. Explicitly excluded from agent context — Injecting "contributor disputed this finding" into the prompt (feat(application-server): inject contributor practice history into agent context #895) creates a feedback loop that contaminates AI accuracy measurement. Feedback is for research analysis and dashboard display only, not for the agent.
  4. Industry validation — CodeRabbit found that emoji-based feedback (thumbs up/down) was too coarse but structured taxonomy works well for aggregate analysis (source). Keep the taxonomy small (3 actions), make it append-only.
  5. Denormalize for research queries — Store practiceSlug and verdict on the feedback entity to enable efficient research queries without joining back to practice_finding.

Schema

CREATE TABLE finding_feedback (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    finding_id      UUID NOT NULL REFERENCES practice_finding(id),
    contributor_id  BIGINT NOT NULL REFERENCES "user"(id),
    action          VARCHAR(16) NOT NULL CHECK (action IN ('APPLIED', 'DISPUTED', 'NOT_APPLICABLE')),
    explanation     TEXT,
    -- Denormalized for research queries
    practice_slug   VARCHAR(64) NOT NULL,
    finding_verdict VARCHAR(16) NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_finding_feedback_finding ON finding_feedback(finding_id);
CREATE INDEX idx_finding_feedback_contributor ON finding_feedback(contributor_id, created_at DESC);

No UNIQUE constraint on (finding_id, contributor_id) — append-only. Multiple feedback events for the same finding are valid (contributor changes mind over time). The latest feedback per finding is the "current" state for dashboard display (query: ORDER BY created_at DESC LIMIT 1).

Action semantics

Action Meaning Maps to legacy Research signal
APPLIED "I fixed/will fix this" FIXED User acted on guidance (RQ2)
DISPUTED "The AI is wrong" WRONG Precision feedback, detection quality (RQ1, RQ4)
NOT_APPLICABLE "Valid observation but not relevant to my context" Scope tuning signal (RQ4)

No DISMISSED — "I saw it but don't care" is the absence of feedback, not a feedback type. It adds noise without research signal.
No ACKNOWLEDGED — same reasoning. Non-action is captured by the lack of a feedback row.
No WONT_FIX — overlaps too much with NOT_APPLICABLE. Revisit after user testing.

explanation field

  • Optional for APPLIED, required for DISPUTED (to feed detection quality improvement)
  • Free text, max 2000 chars
  • DISPUTED explanations are valuable for tuning detection prompts

Scope

Entity

@Entity
@Immutable  // Append-only for research integrity
@Table(name = "finding_feedback")
public class FindingFeedback {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "finding_id", nullable = false)
    private UUID findingId;  // Raw UUID, no @ManyToOne (same pattern as PracticeFinding.agentJobId)

    @Column(name = "contributor_id", nullable = false)
    private Long contributorId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 16)
    private FindingFeedbackAction action;

    @Column(columnDefinition = "TEXT")
    private String explanation;

    // Denormalized
    @Column(name = "practice_slug", nullable = false, length = 64)
    private String practiceSlug;

    @Column(name = "finding_verdict", nullable = false, length = 16)
    private String findingVerdict;

    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;
}

Enum

public enum FindingFeedbackAction {
    APPLIED,
    DISPUTED,
    NOT_APPLICABLE
}

Repository

public interface FindingFeedbackRepository extends JpaRepository<FindingFeedback, UUID> {
    // Latest feedback per finding for dashboard display
    @Query(nativeQuery = true, value = """
        SELECT DISTINCT ON (finding_id) *
        FROM finding_feedback
        WHERE finding_id IN :findingIds
        ORDER BY finding_id, created_at DESC
        """)
    List<FindingFeedback> findLatestByFindingIds(@Param("findingIds") Collection<UUID> findingIds);

    // Engagement stats for contributor
    @Query("""
        SELECT ff.action AS action, COUNT(*) AS count
        FROM FindingFeedback ff
        WHERE ff.contributorId = :contributorId
        GROUP BY ff.action
        """)
    List<Object[]> countByContributorGroupByAction(@Param("contributorId") Long contributorId);
}

Service + Controller

  • FindingFeedbackService.java:

    • Validate finding exists
    • Validate contributor == finding.contributorId (contributor-only access)
    • Populate denormalized fields from the finding
    • Persist (insert, not upsert)
    • For DISPUTED: optionally route to Langfuse (reuse pattern from BadPracticeFeedbackService)
  • FindingFeedbackController.java:

    • POST /api/practices/findings/{findingId}/feedback — submit feedback {action, explanation?}
    • GET /api/practices/findings/{findingId}/feedback — latest feedback for a finding
    • GET /api/practices/findings/me/engagement — engagement stats (action counts)

Integration with #896

Include latest FindingFeedbackDTO in PracticeFindingListDTO and PracticeFindingDetailDTO responses (nullable). This enables the #897 v2 follow-up to show feedback state on practice cards without additional API calls.

Files

File Change
practices/feedback/FindingFeedback.java NEW — @immutable entity
practices/feedback/FindingFeedbackAction.java NEW — enum (3 values)
practices/feedback/FindingFeedbackRepository.java NEW — JPA repo with DISTINCT ON query
practices/feedback/FindingFeedbackService.java NEW — service with contributor auth
practices/feedback/FindingFeedbackController.java NEW — REST controller
practices/feedback/dto/FindingFeedbackDTO.java NEW — response DTO
practices/feedback/dto/CreateFindingFeedbackDTO.java NEW — request DTO
practices/finding/dto/PracticeFindingListDTO.java MODIFY — add nullable feedback field
Liquibase migration NEW — finding_feedback table + indexes

Research Design Decision

Finding feedback is explicitly excluded from agent context (#895). The agent must NOT know whether a contributor previously disputed a finding. Reasons:

  1. Contaminates AI accuracy measurement (the agent may soften future assessments to avoid disputes)
  2. Creates a perverse incentive (dispute everything → agent stops flagging)
  3. First establish baseline detection quality, then decide whether to close the feedback loop

Verification

  • POST feedback: APPLIED, DISPUTED, NOT_APPLICABLE all work
  • Append-only: second feedback for same finding creates new row (not upsert)
  • Only the finding's contributor can submit feedback (403 for others)
  • DISPUTED requires explanation field (400 without it)
  • GET engagement returns correct action counts
  • PracticeFindingListDTO includes latest feedback when present
  • DISPUTED feedback with explanation routes to Langfuse (if configured)
  • Denormalized practiceSlug and findingVerdict populated correctly

Dependencies

Metadata

Metadata

Assignees

No one assigned

    Labels

    application-serverSpring Boot server: APIs, business logic, databaseenhancementImprovement to existing functionalityfeatureNew feature or enhancement

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions