feat: document language checker on round overview
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m15s
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:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user