feat: document language checker on round overview
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m15s

- New roundLanguageSummary query in file router aggregates per-round document
  language data from existing detectedLang/langConfidence fields
- Document Languages card on round overview tab shows analysis status and
  flags non-English documents grouped by project with confidence scores
- Green border when all documents are English, amber when issues detected
- Project names link to project detail page for easy navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 11:53:43 +01:00
parent d4c946470a
commit 3180bfa946
2 changed files with 135 additions and 0 deletions

View File

@@ -77,6 +77,8 @@ import {
ArrowRight, ArrowRight,
RotateCcw, RotateCcw,
ListChecks, ListChecks,
FileText,
Languages,
} from 'lucide-react' } from 'lucide-react'
import { import {
Tooltip, Tooltip,
@@ -1471,6 +1473,9 @@ export default function RoundDetailPage() {
</Card> </Card>
</AnimatedCard> </AnimatedCard>
</div> </div>
{/* Document Language Summary */}
<DocumentLanguageSummary roundId={roundId as string} />
</TabsContent> </TabsContent>
{/* ═══════════ PROJECTS TAB ═══════════ */} {/* ═══════════ PROJECTS TAB ═══════════ */}
@@ -2482,3 +2487,75 @@ export default function RoundDetailPage() {
</div> </div>
) )
} }
// =============================================================================
// Document Language Summary — flags non-English docs
// =============================================================================
const LANG_NAMES: Record<string, string> = {
eng: 'English', fra: 'French', deu: 'German', spa: 'Spanish', ita: 'Italian',
por: 'Portuguese', nld: 'Dutch', rus: 'Russian', ara: 'Arabic', zho: 'Chinese',
jpn: 'Japanese', kor: 'Korean', tur: 'Turkish', pol: 'Polish', ron: 'Romanian',
ces: 'Czech', ell: 'Greek', hun: 'Hungarian', swe: 'Swedish', dan: 'Danish',
fin: 'Finnish', nor: 'Norwegian', und: 'Unknown',
}
function DocumentLanguageSummary({ roundId }: { roundId: string }) {
const { data, isLoading } = trpc.file.roundLanguageSummary.useQuery(
{ roundId },
{ refetchInterval: 60_000 }
)
if (isLoading || !data) return null
if (data.totalFiles === 0) return null
const allGood = data.nonEnglishCount === 0 && data.unanalyzedCount === 0
return (
<Card className={allGood ? 'border-green-200 bg-green-50/30' : data.nonEnglishCount > 0 ? 'border-amber-300 bg-amber-50/30' : ''}>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Languages className={cn('h-4 w-4', allGood ? 'text-green-600' : data.nonEnglishCount > 0 ? 'text-amber-600' : 'text-muted-foreground')} />
Document Languages
{data.nonEnglishCount > 0 && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
{data.nonEnglishCount} flagged
</Badge>
)}
</CardTitle>
<CardDescription className="text-xs">
{data.analyzedCount} of {data.totalFiles} documents analyzed
{data.unanalyzedCount > 0 && `${data.unanalyzedCount} pending`}
</CardDescription>
</CardHeader>
{data.nonEnglishCount > 0 && (
<CardContent className="space-y-2">
{data.flaggedProjects.map((project) => (
<div key={project.projectId} className="rounded-md border bg-white p-3">
<Link
href={`/admin/projects/${project.projectId}` as Route}
className="text-sm font-medium text-primary hover:underline"
>
{project.projectTitle}
</Link>
<div className="mt-1.5 space-y-1">
{project.files.map((file) => (
<div key={file.id} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 min-w-0">
<FileText className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="truncate">{file.fileName}</span>
</div>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0 ml-2 border-amber-300 text-amber-700">
{LANG_NAMES[file.detectedLang ?? ''] || file.detectedLang}
{file.langConfidence != null && ` (${Math.round(file.langConfidence * 100)}%)`}
</Badge>
</div>
))}
</div>
</div>
))}
</CardContent>
)}
</Card>
)
}

View File

@@ -1622,4 +1622,62 @@ export const fileRouter = router({
const { analyzeAllUnanalyzed } = await import('../services/document-analyzer') const { analyzeAllUnanalyzed } = await import('../services/document-analyzer')
return analyzeAllUnanalyzed() return analyzeAllUnanalyzed()
}), }),
/**
* Get document language summary for a specific round.
* Returns per-project file language data, flagging non-English documents.
*/
roundLanguageSummary: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const files = await ctx.prisma.projectFile.findMany({
where: { roundId: input.roundId },
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
pageCount: true,
detectedLang: true,
langConfidence: true,
analyzedAt: true,
project: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
})
const totalFiles = files.length
const analyzedFiles = files.filter((f) => f.analyzedAt !== null)
const nonEnglish = analyzedFiles.filter(
(f) => f.detectedLang && f.detectedLang !== 'eng' && f.detectedLang !== 'und' && (f.langConfidence ?? 0) >= 0.4
)
const unanalyzed = files.filter((f) => f.analyzedAt === null)
// Group non-English files by project
const projectMap = new Map<string, {
projectId: string
projectTitle: string
files: typeof nonEnglish
}>()
for (const file of nonEnglish) {
const existing = projectMap.get(file.project.id)
if (existing) {
existing.files.push(file)
} else {
projectMap.set(file.project.id, {
projectId: file.project.id,
projectTitle: file.project.title,
files: [file],
})
}
}
return {
totalFiles,
analyzedCount: analyzedFiles.length,
unanalyzedCount: unanalyzed.length,
nonEnglishCount: nonEnglish.length,
flaggedProjects: Array.from(projectMap.values()),
}
}),
}) })