refactor(#240): remove needsExpert feature completely
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m23s
CI / Backend Unit Tests (pull_request) Failing after 2m43s
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running

Drops the needsExpert / needs_expert flag end-to-end: DB migration
(V37, never applied), Document entity field, PATCH endpoint, service
method, DTO field, all three queue queries, ExpertBadge component,
i18n key, generated API types, and test fixture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 10:52:14 +02:00
parent 9fb1821db5
commit ca0cf4903c
17 changed files with 13 additions and 91 deletions

View File

@@ -211,14 +211,6 @@ public class DocumentController {
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir)); return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir));
} }
// --- EXPERT FLAG ---
@PatchMapping("/{id}/needs-expert")
@RequirePermission(Permission.WRITE_ALL)
public Document toggleNeedsExpert(@PathVariable UUID id) {
return documentService.toggleNeedsExpert(id);
}
// --- TRAINING LABELS --- // --- TRAINING LABELS ---
public record TrainingLabelRequest(String label, boolean enrolled) {} public record TrainingLabelRequest(String label, boolean enrolled) {}

View File

@@ -12,7 +12,6 @@ public record TranscriptionQueueItemDTO(
UUID id, UUID id,
String title, String title,
LocalDate documentDate, LocalDate documentDate,
boolean needsExpert,
int annotationCount, int annotationCount,
int textedBlockCount, int textedBlockCount,
int reviewedBlockCount int reviewedBlockCount

View File

@@ -97,11 +97,6 @@ public class Document {
@Builder.Default @Builder.Default
private ScriptType scriptType = ScriptType.UNKNOWN; private ScriptType scriptType = ScriptType.UNKNOWN;
@Column(name = "needs_expert", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private boolean needsExpert = false;
@ManyToMany(fetch = FetchType.EAGER) @ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id")) @JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
@Builder.Default @Builder.Default

View File

@@ -171,27 +171,26 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
/** Documents with no annotations — Segmentierung column. */ /** Documents with no annotations — Segmentierung column. */
@Query(nativeQuery = true, value = """ @Query(nativeQuery = true, value = """
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert, SELECT d.id, d.title, d.meta_date AS documentDate,
0 AS annotationCount, 0 AS textedBlockCount, 0 AS reviewedBlockCount 0 AS annotationCount, 0 AS textedBlockCount, 0 AS reviewedBlockCount
FROM documents d FROM documents d
WHERE d.status NOT IN ('PLACEHOLDER') WHERE d.status NOT IN ('PLACEHOLDER')
AND NOT EXISTS (SELECT 1 FROM document_annotations da WHERE da.document_id = d.id) AND NOT EXISTS (SELECT 1 FROM document_annotations da WHERE da.document_id = d.id)
ORDER BY d.needs_expert ASC, ORDER BY HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
LIMIT :limit LIMIT :limit
""") """)
List<Object[]> findSegmentationQueue(@Param("limit") int limit); List<Object[]> findSegmentationQueue(@Param("limit") int limit);
/** Documents with annotations but not yet fully reviewed — Transkription column. */ /** Documents with annotations but not yet fully reviewed — Transkription column. */
@Query(nativeQuery = true, value = """ @Query(nativeQuery = true, value = """
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert, SELECT d.id, d.title, d.meta_date AS documentDate,
COUNT(DISTINCT da.id) AS annotationCount, COUNT(DISTINCT da.id) AS annotationCount,
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) AS textedBlockCount, COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) AS textedBlockCount,
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END) AS reviewedBlockCount COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END) AS reviewedBlockCount
FROM documents d FROM documents d
JOIN document_annotations da ON da.document_id = d.id JOIN document_annotations da ON da.document_id = d.id
LEFT JOIN transcription_blocks tb ON tb.document_id = d.id LEFT JOIN transcription_blocks tb ON tb.document_id = d.id
GROUP BY d.id, d.title, d.meta_date, d.needs_expert GROUP BY d.id, d.title, d.meta_date
HAVING COUNT(DISTINCT da.id) > 0 HAVING COUNT(DISTINCT da.id) > 0
AND ( AND (
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) = 0 COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) = 0
@@ -200,8 +199,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
NULLIF(COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END), 0) NULLIF(COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END), 0)
) < 0.90 ) < 0.90
) )
ORDER BY d.needs_expert ASC, ORDER BY COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) DESC,
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) DESC,
HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text) HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
LIMIT :limit LIMIT :limit
""") """)
@@ -209,14 +207,14 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
/** Documents with reviewed_pct >= 90 % — Lesefertig column. */ /** Documents with reviewed_pct >= 90 % — Lesefertig column. */
@Query(nativeQuery = true, value = """ @Query(nativeQuery = true, value = """
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert, SELECT d.id, d.title, d.meta_date AS documentDate,
COUNT(DISTINCT da.id) AS annotationCount, COUNT(DISTINCT da.id) AS annotationCount,
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) AS textedBlockCount, COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) AS textedBlockCount,
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END) AS reviewedBlockCount COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END) AS reviewedBlockCount
FROM documents d FROM documents d
JOIN document_annotations da ON da.document_id = d.id JOIN document_annotations da ON da.document_id = d.id
LEFT JOIN transcription_blocks tb ON tb.document_id = d.id LEFT JOIN transcription_blocks tb ON tb.document_id = d.id
GROUP BY d.id, d.title, d.meta_date, d.needs_expert GROUP BY d.id, d.title, d.meta_date
HAVING COUNT(DISTINCT da.id) > 0 HAVING COUNT(DISTINCT da.id) > 0
AND COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) > 0 AND COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) > 0
AND ( AND (

View File

@@ -577,13 +577,6 @@ public class DocumentService {
return parsed != null ? parsed.title() : stripExtension(filename); return parsed != null ? parsed.title() : stripExtension(filename);
} }
@Transactional
public Document toggleNeedsExpert(UUID documentId) {
Document doc = getDocumentById(documentId);
doc.setNeedsExpert(!doc.isNeedsExpert());
return documentRepository.save(doc);
}
private static String tryParseDate(String s) { private static String tryParseDate(String s) {
if (s.matches("\\d{4}-\\d{2}-\\d{2}")) { if (s.matches("\\d{4}-\\d{2}-\\d{2}")) {
int m = Integer.parseInt(s.substring(5, 7)); int m = Integer.parseInt(s.substring(5, 7));

View File

@@ -59,11 +59,10 @@ public class TranscriptionQueueService {
UUID id = toUUID(row[0]); UUID id = toUUID(row[0]);
String title = (String) row[1]; String title = (String) row[1];
LocalDate documentDate = toLocalDate(row[2]); LocalDate documentDate = toLocalDate(row[2]);
boolean needsExpert = toBoolean(row[3]); int annotationCount = toInt(row[3]);
int annotationCount = toInt(row[4]); int textedBlockCount = toInt(row[4]);
int textedBlockCount = toInt(row[5]); int reviewedBlockCount = toInt(row[5]);
int reviewedBlockCount = toInt(row[6]); return new TranscriptionQueueItemDTO(id, title, documentDate,
return new TranscriptionQueueItemDTO(id, title, documentDate, needsExpert,
annotationCount, textedBlockCount, reviewedBlockCount); annotationCount, textedBlockCount, reviewedBlockCount);
} }
@@ -79,11 +78,6 @@ public class TranscriptionQueueService {
return LocalDate.parse(o.toString()); return LocalDate.parse(o.toString());
} }
private boolean toBoolean(Object o) {
if (o instanceof Boolean b) return b;
return Boolean.parseBoolean(o.toString());
}
private int toInt(Object o) { private int toInt(Object o) {
if (o == null) return 0; if (o == null) return 0;
if (o instanceof Number n) return n.intValue(); if (o instanceof Number n) return n.intValue();

View File

@@ -1 +0,0 @@
ALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -573,7 +573,6 @@
"mission_control_ready_empty": "Noch keine Dokumente vollständig transkribiert.", "mission_control_ready_empty": "Noch keine Dokumente vollständig transkribiert.",
"mission_control_ready_empty_cta": "Jetzt mitmachen", "mission_control_ready_empty_cta": "Jetzt mitmachen",
"mission_control_weekly_pulse": "↑ +{count} diese Woche", "mission_control_weekly_pulse": "↑ +{count} diese Woche",
"mission_control_expert_badge": "Experten gesucht",
"mission_control_blocks_progress": "{texted} / {total} Blöcke", "mission_control_blocks_progress": "{texted} / {total} Blöcke",
"mission_control_reviewed_pct": "{pct}% geprüft" "mission_control_reviewed_pct": "{pct}% geprüft"
} }

View File

@@ -573,7 +573,6 @@
"mission_control_ready_empty": "No documents fully transcribed yet.", "mission_control_ready_empty": "No documents fully transcribed yet.",
"mission_control_ready_empty_cta": "Start contributing", "mission_control_ready_empty_cta": "Start contributing",
"mission_control_weekly_pulse": "↑ +{count} this week", "mission_control_weekly_pulse": "↑ +{count} this week",
"mission_control_expert_badge": "Expert needed",
"mission_control_blocks_progress": "{texted} / {total} blocks", "mission_control_blocks_progress": "{texted} / {total} blocks",
"mission_control_reviewed_pct": "{pct}% reviewed" "mission_control_reviewed_pct": "{pct}% reviewed"
} }

View File

@@ -573,7 +573,6 @@
"mission_control_ready_empty": "Aún no hay documentos completamente transcritos.", "mission_control_ready_empty": "Aún no hay documentos completamente transcritos.",
"mission_control_ready_empty_cta": "Empezar a colaborar", "mission_control_ready_empty_cta": "Empezar a colaborar",
"mission_control_weekly_pulse": "↑ +{count} esta semana", "mission_control_weekly_pulse": "↑ +{count} esta semana",
"mission_control_expert_badge": "Se busca experto",
"mission_control_blocks_progress": "{texted} / {total} bloques", "mission_control_blocks_progress": "{texted} / {total} bloques",
"mission_control_reviewed_pct": "{pct}% revisado" "mission_control_reviewed_pct": "{pct}% revisado"
} }

View File

@@ -1,26 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
</script>
<span
class="inline-flex items-center gap-1 rounded border border-purple-200 bg-purple-50 px-2 py-0.5 text-xs font-semibold text-purple-700"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 1.5L1.5 13.5h13L8 1.5z"
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
fill="none"
/>
<path d="M8 6v3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<circle cx="8" cy="11.5" r="0.75" fill="currentColor" />
</svg>
{m.mission_control_expert_badge()}
</span>

View File

@@ -8,7 +8,6 @@ type TranscriptionQueueItemDTO = {
id: string; id: string;
title: string; title: string;
documentDate?: string; documentDate?: string;
needsExpert: boolean;
annotationCount: number; annotationCount: number;
textedBlockCount: number; textedBlockCount: number;
reviewedBlockCount: number; reviewedBlockCount: number;

View File

@@ -6,7 +6,6 @@ type TranscriptionQueueItemDTO = {
id: string; id: string;
title: string; title: string;
documentDate?: string; documentDate?: string;
needsExpert: boolean;
annotationCount: number; annotationCount: number;
textedBlockCount: number; textedBlockCount: number;
reviewedBlockCount: number; reviewedBlockCount: number;

View File

@@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js'; import { getLocale } from '$lib/paraglide/runtime.js';
import ExpertBadge from './ExpertBadge.svelte';
type TranscriptionQueueItemDTO = { type TranscriptionQueueItemDTO = {
id: string; id: string;
title: string; title: string;
documentDate?: string; documentDate?: string;
needsExpert: boolean;
annotationCount: number; annotationCount: number;
textedBlockCount: number; textedBlockCount: number;
reviewedBlockCount: number; reviewedBlockCount: number;
@@ -53,12 +51,7 @@ function formatDate(dateStr: string): string {
href="/documents/{doc.id}" href="/documents/{doc.id}"
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none" class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
> >
<div class="flex flex-wrap items-center gap-1.5"> <span class="font-serif text-sm text-ink">{doc.title}</span>
<span class="font-serif text-sm text-ink">{doc.title}</span>
{#if doc.needsExpert}
<ExpertBadge />
{/if}
</div>
{#if doc.documentDate} {#if doc.documentDate}
<span class="mt-0.5 text-xs text-ink-3">{formatDate(doc.documentDate)}</span> <span class="mt-0.5 text-xs text-ink-3">{formatDate(doc.documentDate)}</span>
{/if} {/if}

View File

@@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js'; import { getLocale } from '$lib/paraglide/runtime.js';
import ExpertBadge from './ExpertBadge.svelte';
type TranscriptionQueueItemDTO = { type TranscriptionQueueItemDTO = {
id: string; id: string;
title: string; title: string;
documentDate?: string; documentDate?: string;
needsExpert: boolean;
annotationCount: number; annotationCount: number;
textedBlockCount: number; textedBlockCount: number;
reviewedBlockCount: number; reviewedBlockCount: number;
@@ -58,12 +56,7 @@ function blockProgress(doc: TranscriptionQueueItemDTO): number {
href="/documents/{doc.id}" href="/documents/{doc.id}"
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none" class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
> >
<div class="flex flex-wrap items-center gap-1.5"> <span class="font-serif text-sm text-ink">{doc.title}</span>
<span class="font-serif text-sm text-ink">{doc.title}</span>
{#if doc.needsExpert}
<ExpertBadge />
{/if}
</div>
{#if doc.documentDate} {#if doc.documentDate}
<span class="mt-0.5 text-xs text-ink-3">{formatDate(doc.documentDate)}</span> <span class="mt-0.5 text-xs text-ink-3">{formatDate(doc.documentDate)}</span>
{/if} {/if}

View File

@@ -1225,7 +1225,6 @@ export interface components {
metadataComplete: boolean; metadataComplete: boolean;
/** @enum {string} */ /** @enum {string} */
scriptType: "UNKNOWN" | "TYPEWRITER" | "HANDWRITING_LATIN" | "HANDWRITING_KURRENT"; scriptType: "UNKNOWN" | "TYPEWRITER" | "HANDWRITING_LATIN" | "HANDWRITING_KURRENT";
needsExpert: boolean;
receivers?: components["schemas"]["Person"][]; receivers?: components["schemas"]["Person"][];
sender?: components["schemas"]["Person"]; sender?: components["schemas"]["Person"];
tags?: components["schemas"]["Tag"][]; tags?: components["schemas"]["Tag"][];
@@ -1489,7 +1488,6 @@ export interface components {
title: string; title: string;
/** Format: date */ /** Format: date */
documentDate?: string; documentDate?: string;
needsExpert: boolean;
/** Format: int32 */ /** Format: int32 */
annotationCount: number; annotationCount: number;
/** Format: int32 */ /** Format: int32 */

View File

@@ -39,7 +39,6 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
location: 'Berlin', location: 'Berlin',
metadataComplete: false, metadataComplete: false,
scriptType: 'UNKNOWN' as const, scriptType: 'UNKNOWN' as const,
needsExpert: false,
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' }, sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }], receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
tags: [], tags: [],