OSA.Edu / src /components /RepositoryQualityPage.tsx
tityos's picture
feat: translate authors to en, add open-weights models to field.
d06a6cf
Raw History Blame Contribute Delete
20.4 kB
import { useEffect, useMemo, useRef, useState } from "react";
import { loadMockJson, sleep, useDemoCase } from "../demo";
import { useLanguage, type UiLanguage } from "../i18n";
type CheckTone = "pass" | "fail" | "uncertain" | "na";
type QualityCheckView = {
key: string;
label: string;
description: string;
tone: CheckTone;
status: string;
details?: string;
recommendation?: string;
weight?: number;
earned?: number;
};
type QualityReportView = {
repository: string;
analyzedAt?: string;
score: number;
repoType: string;
repoTypeLabel: string;
repoTypeConfidence?: string;
repoTypeError?: string;
checks: QualityCheckView[];
informational: QualityCheckView[];
};
const COPY = {
ru: {
title: "Качество репозитория",
subtitle: "Оценка структуры, документации и воспроизводимости проекта.",
run: "Оценить репозиторий",
running: "Анализ репозитория…",
completed: "Проверка качества репозитория завершена.",
type: "Тип",
confidence: "Уверенность",
score: "Оценка",
analyzed: "Проверено",
criteriaTitle: "Основные критерии",
infoKicker: "Не влияют на итоговую оценку",
infoTitle: "Дополнительные проверки",
result: "Результат",
whatToDo: "Что сделать",
whatToCheck: "Что проверить",
passed: "Выполнено",
needsWork: "Нужно улучшить",
manual: "Проверьте вручную",
na: "Не применяется",
downloadJson: "Скачать JSON",
noCase: "Выберите диссертацию выше.",
uncertainType: "Тип репозитория определён неуверенно",
uncertainTypeText: "Проверьте, что определённый тип соответствует назначению проекта.",
},
en: {
title: "Repository quality",
subtitle: "Evaluate project structure, documentation, and reproducibility.",
run: "Evaluate repository",
running: "Analyzing repository…",
completed: "Repository quality check completed.",
type: "Type",
confidence: "Confidence",
score: "Score",
analyzed: "Analyzed",
criteriaTitle: "Scored criteria",
infoKicker: "Does not affect the final score",
infoTitle: "Additional checks",
result: "Result",
whatToDo: "What to improve",
whatToCheck: "What to verify",
passed: "Passed",
needsWork: "Needs improvement",
manual: "Manual review",
na: "Not applicable",
downloadJson: "Download JSON",
noCase: "Select a thesis above.",
uncertainType: "Repository type is uncertain",
uncertainTypeText: "Verify that the detected type matches the project.",
},
} as const;
const CHECK_COPY = {
ru: {
readme: ["README", "В корне репозитория есть содержательный README.", "Добавьте или дополните README: назначение проекта, запуск, основные возможности и пример использования."],
license: ["Лицензия", "В репозитории есть файл лицензии.", "Добавьте LICENSE с подходящей лицензией."],
commits: ["История разработки", "В истории репозитория не менее пяти коммитов.", "Сохраняйте этапы разработки отдельными осмысленными коммитами."],
execution_files: ["Точка запуска", "В проекте удалось определить точку запуска.", "Добавьте понятную точку запуска и опишите команду запуска в README."],
requirements: ["Зависимости", "Зависимости проекта зафиксированы.", "Добавьте requirements.txt или pyproject.toml с воспроизводимым набором зависимостей."],
tests: ["Тесты", "В приложении обнаружены тесты.", "Добавьте автоматические тесты для основной логики."],
data_files: ["Данные", "Для экспериментального проекта найдены используемые данные или связанные файлы.", "Добавьте данные, ссылки на них или инструкцию по получению данных."],
experiment_scripts: ["Экспериментальные сценарии", "Найдены скрипты, позволяющие воспроизвести эксперименты.", "Добавьте отдельные команды/скрипты экспериментов."],
syntax: ["Синтаксис Python", "Проверка синтаксиса Python-файлов.", "Исправьте найденные синтаксические ошибки."],
docstrings: ["Документация кода", "Оценка покрытия функций и классов docstrings.", "Добавьте docstrings там, где их не хватает."],
},
en: {
readme: ["README", "A meaningful README is present at the repository root.", "Add or improve the README with the project purpose, setup, main features, and a usage example."],
license: ["License", "A license file is present in the repository.", "Add an appropriate LICENSE file."],
commits: ["Development history", "The repository contains at least five commits.", "Keep meaningful development stages as separate commits."],
execution_files: ["Entry point", "A project entry point can be identified.", "Add a clear entry point and document the launch command in the README."],
requirements: ["Dependencies", "Project dependencies are declared.", "Add requirements.txt or pyproject.toml with a reproducible dependency set."],
tests: ["Tests", "Automated tests were found for the application.", "Add automated tests for the core logic."],
data_files: ["Data", "Data files or related artifacts were found for the experimental project.", "Add the data, links to it, or clear instructions for obtaining it."],
experiment_scripts: ["Experiment scripts", "Scripts that can reproduce the experiments were found.", "Add dedicated experiment scripts/commands."],
syntax: ["Python syntax", "Checks Python files for syntax errors.", "Fix the reported syntax errors."],
docstrings: ["Code documentation", "Measures docstring coverage for functions and classes.", "Add docstrings where coverage is low."],
},
} as const;
const TYPE_LABELS = {
ru: { app: "Приложение", algorithm_experiments: "Алгоритмические эксперименты", model_training_experiments: "Обучение моделей / ML-эксперименты", unknown: "Тип не определён" },
en: { app: "Application", algorithm_experiments: "Algorithm experiments", model_training_experiments: "Model training / ML experiments", unknown: "Unknown type" },
} as const;
export function RepositoryQualityPage() {
const { language } = useLanguage();
const { selectedCase, selectedModel } = useDemoCase();
const t = COPY[language];
const [raw, setRaw] = useState<unknown>(null);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState("");
const [done, setDone] = useState(false);
const generation = useRef(0);
useEffect(() => {
generation.current += 1;
setRaw(null);
setRunning(false);
setProgress(0);
setError("");
setDone(false);
}, [selectedCase?.id, selectedModel.id]);
const report = useMemo(() => raw ? normalizeReport(raw, language) : null, [raw, language]);
async function runCheck() {
if (!selectedCase || running) return;
const token = ++generation.current;
setRaw(null);
setError("");
setDone(false);
setRunning(true);
setProgress(0);
const start = performance.now();
const timer = window.setInterval(() => {
const pct = Math.min(96, Math.round(((performance.now() - start) / 5000) * 100));
setProgress(pct);
}, 100);
await sleep(5000);
window.clearInterval(timer);
if (token !== generation.current) return;
try {
const value = await loadMockJson(selectedCase.repositoryQualityJson);
if (token !== generation.current) return;
setRaw(value);
setProgress(100);
setDone(true);
} catch (runError) {
if (token !== generation.current) return;
setError(runError instanceof Error ? runError.message : String(runError));
} finally {
if (token === generation.current) setRunning(false);
}
}
return <section className="page narrow repository-quality-page">
<header className="rq-hero">
<h1>{t.title}</h1>
<p>{t.subtitle}</p>
</header>
<div className="panel demo-launch-card">
<div>
<strong>{selectedCase?.author || t.noCase}</strong>
{selectedCase?.repository && <span>{selectedCase.repository} · {selectedModel.label}</span>}
</div>
<button className="button primary" type="button" onClick={() => void runCheck()} disabled={!selectedCase || running}>
{running ? t.running : t.run}
</button>
</div>
{(running || done) && <section className="panel demo-run-state">
<div className="demo-run-line">
<span className={`demo-spinner ${done ? "done" : ""}`}>{done ? "✓" : ""}</span>
<div><strong>{done ? t.completed : t.running}</strong><span>{progress}%</span></div>
</div>
<progress max="100" value={progress} />
</section>}
{error && <div className="inline-error rq-inline-message">{error}</div>}
{report && <>
<section className="panel rq-result-summary">
<div className="rq-result-main">
<span className="rq-result-kicker">{shortRepository(report.repository)}</span>
<div className="rq-score-line"><strong>{report.score}</strong><span>/100</span></div>
<span className="rq-score-caption">{t.score}</span>
</div>
<div className="rq-result-meta">
<div><span>{t.type}</span><strong>{report.repoTypeLabel}</strong></div>
{report.repoTypeConfidence && <div><span>{t.confidence}</span><strong>{confidenceLabel(report.repoTypeConfidence, language)}</strong></div>}
{report.analyzedAt && <div><span>{t.analyzed}</span><strong>{formatDateTime(report.analyzedAt, language)}</strong></div>}
</div>
</section>
{(report.repoTypeError || report.repoTypeConfidence === "low") && <div className="rq-warning panel">
<strong>{t.uncertainType}</strong><span>{t.uncertainTypeText}</span>
</div>}
<section className="rq-criteria-section">
<div className="rq-section-heading rq-section-heading-inline">
<div><h2>{t.criteriaTitle}</h2></div>
{selectedCase && <a className="text-button rq-json-link" href={selectedCase.repositoryQualityJson} download>{t.downloadJson}</a>}
</div>
<div className="panel rq-check-list">
{report.checks.map((check) => <article key={check.key} className={`rq-check-card ${check.tone}`}>
<div className="rq-check-head">
<div className="rq-check-heading"><span className={`status-badge ${toneClass(check.tone)}`}>{checkStatusLabel(check.tone, language)}</span><h3>{check.label}</h3></div>
{typeof check.weight === "number" && <strong className="rq-points">{check.earned ?? 0}/{check.weight}</strong>}
</div>
<p className="rq-check-description">{check.description}</p>
<div className="rq-check-status"><span>{t.result}</span><p>{check.status}</p></div>
{check.details && <p className="rq-check-details">{check.details}</p>}
{check.recommendation && check.tone !== "pass" && check.tone !== "na" && <div className="rq-recommendation"><strong>{check.tone === "uncertain" ? t.whatToCheck : t.whatToDo}</strong><span>{check.recommendation}</span></div>}
</article>)}
</div>
</section>
{report.informational.length > 0 && <section className="rq-info-section">
<div className="rq-section-heading"><span>{t.infoKicker}</span><h2>{t.infoTitle}</h2></div>
<div className="panel rq-info-list">{report.informational.map((check) => <div key={check.key} className="rq-info-card"><strong>{check.label}</strong><p>{check.status}</p>{check.details && <span>{check.details}</span>}</div>)}</div>
</section>}
</>}
</section>;
}
function normalizeReport(raw: unknown, language: UiLanguage): QualityReportView {
const root = asRecord(raw);
const report = Object.keys(asRecord(root.result)).length ? asRecord(root.result) : root;
const summary = asRecord(report.summary);
const checks = asRecord(report.checks);
const breakdown = asRecord(summary.score_breakdown);
const repoTypeRaw = asRecord(checks.repo_type);
const repoType = pickString(summary, ["repo_type"]) || pickString(repoTypeRaw, ["value"]) || "unknown";
const weighted = Object.entries(breakdown).map(([key, rawBreakdown]) => buildCheckView(key, asRecord(checks[key]), asRecord(rawBreakdown), language));
const informational = ["syntax", "docstrings"].filter((key) => checks[key]).map((key) => buildCheckView(key, asRecord(checks[key]), {}, language, true));
const typeLabels = TYPE_LABELS[language] as Record<string, string>;
return {
repository: pickString(report, ["repo_url"]) || "Repository",
analyzedAt: pickString(report, ["analyzed_at"]),
score: pickNumber(summary, ["score"]) ?? 0,
repoType,
repoTypeLabel: typeLabels[repoType] || humanize(repoType),
repoTypeConfidence: pickString(repoTypeRaw, ["confidence"]),
repoTypeError: pickString(repoTypeRaw, ["error"]),
checks: weighted,
informational,
};
}
function buildCheckView(key: string, raw: Record<string, any>, score: Record<string, any>, language: UiLanguage, informational = false): QualityCheckView {
const languageCopy = CHECK_COPY[language] as Record<string, readonly [string, string, string]>;
const fallback: readonly [string, string, string] = language === "ru"
? [humanize(key), "Проверка критерия.", "Проверьте критерий вручную."]
: [humanize(key), "Criterion check.", "Review this criterion manually."];
const entry = languageCopy[key] || fallback;
const applicable = raw.applicable !== false && score.applicable !== false;
const llmError = pickString(raw, ["error"]);
const passed = typeof score.passed === "boolean" ? score.passed : inferPassed(key, raw);
let tone: CheckTone = applicable ? (passed ? "pass" : "fail") : "na";
if (llmError) tone = "uncertain";
let status = language === "ru"
? (tone === "pass" ? "Критерий выполнен." : tone === "na" ? "Критерий не применяется к этому типу репозитория." : "Критерий не выполнен.")
: (tone === "pass" ? "Criterion passed." : tone === "na" ? "This criterion does not apply to this repository type." : "Criterion not met.");
if (llmError === "llm_failed") status = language === "ru" ? "Не удалось однозначно определить результат этой проверки." : "This result could not be determined reliably.";
else if (llmError) status = language === "ru" ? `Проверка завершилась неоднозначно: ${llmError}.` : `The check was inconclusive: ${llmError}.`;
if (key === "readme") {
if (!raw.present) status = language === "ru" ? "README в корне репозитория не найден." : "No README was found at the repository root.";
else if (raw.meaningful === false) status = language === "ru" ? "README найден, но он недостаточно подробный." : "README was found, but it is not detailed enough.";
else if (tone === "pass") status = language === "ru" ? "README найден." : "README found.";
} else if (key === "commits" && typeof raw.count === "number") {
status = language === "ru" ? `${raw.count} коммитов найдено.` : `${raw.count} commits found.`;
} else if (pickString(raw, ["matched_file"])) {
status = language === "ru" ? `Найден файл: ${pickString(raw, ["matched_file"])}.` : `Found: ${pickString(raw, ["matched_file"])}.`;
} else if (Array.isArray(raw.verified) && raw.verified.length) {
status = language === "ru" ? `Подтверждено: ${raw.verified.slice(0, 4).join(", ")}.` : `Verified: ${raw.verified.slice(0, 4).join(", ")}.`;
} else if (informational && pickString(raw, ["summary"])) {
status = pickString(raw, ["summary"]) || status;
}
const details = buildDetails(raw, language);
const recommendation = tone === "uncertain"
? `${language === "ru" ? "Проверьте этот критерий вручную." : "Review this criterion manually."} ${entry[2]}`
: entry[2];
return { key, label: entry[0], description: entry[1], tone, status, details, recommendation, weight: pickNumber(score, ["weight"]), earned: pickNumber(score, ["earned"]) };
}
function inferPassed(key: string, raw: Record<string, any>): boolean {
if (key === "readme") return Boolean(raw.present && raw.meaningful);
return Boolean(raw.present);
}
function buildDetails(raw: Record<string, any>, language: UiLanguage): string | undefined {
const parts: string[] = [];
const reason = pickString(raw, ["reason", "detail", "message"]);
if (reason) parts.push(reason);
if (Array.isArray(raw.verified) && raw.verified.length) parts.push(`${language === "ru" ? "Файлы" : "Files"}: ${raw.verified.slice(0, 6).join(", ")}.`);
if (Array.isArray(raw.missing) && raw.missing.length) parts.push(`${language === "ru" ? "Не найдено" : "Missing"}: ${raw.missing.slice(0, 6).join(", ")}.`);
return parts.length ? parts.join(" ") : undefined;
}
function toneClass(tone: CheckTone): "pass" | "violation" | "uncertain" | "not_applicable" {
if (tone === "pass") return "pass";
if (tone === "fail") return "violation";
if (tone === "uncertain") return "uncertain";
return "not_applicable";
}
function checkStatusLabel(tone: CheckTone, language: UiLanguage): string {
const t = COPY[language];
if (tone === "pass") return t.passed;
if (tone === "fail") return t.needsWork;
if (tone === "uncertain") return t.manual;
return t.na;
}
function shortRepository(value: string): string {
try { const url = new URL(value); return url.pathname.replace(/^\//, "").replace(/\.git$/, "") || value; }
catch { return value; }
}
function confidenceLabel(value: string, language: UiLanguage): string {
const normalized = value.toLowerCase();
if (language === "ru") return normalized === "high" ? "высокая" : normalized === "medium" ? "средняя" : normalized === "low" ? "низкая" : value;
return normalized;
}
function humanize(value: string): string {
const normalized = value.replace(/_/g, " ");
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}
function asRecord(value: unknown): Record<string, any> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
}
function pickString(record: Record<string, any>, keys: string[]): string | undefined {
for (const key of keys) { const value = record[key]; if (typeof value === "string" && value.trim()) return value.trim(); }
return undefined;
}
function pickNumber(record: Record<string, any>, keys: string[]): number | undefined {
for (const key of keys) { const value = record[key]; if (typeof value === "number" && Number.isFinite(value)) return value; }
return undefined;
}
function formatDateTime(value: string | null | undefined, language: UiLanguage): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString(language === "ru" ? "ru-RU" : "en-GB", { dateStyle: "short", timeStyle: "short" });
}