feat: expandable previews, KaTeX rendering, variant speedup, batch import

- Analytics/Similar: expandable question preview with KaTeX rendering
- KaTeXRenderer: auto markdown-to-HTML (code blocks, tables, bold), auto Unicode→LaTeX
- ErrorBook: full question text rendering instead of truncated preview
- Variant: remove hint/solution from generation (faster), async, fix null crash
- Grading: add max_tokens limit
- JSON parser: robust multi-layer repair + JSONDecodeError retry
- Extraction prompt: enforce LaTeX notation for math
- Upload: redirect to home instead of blank paper page
- ProcessingBanner: add ETA time estimate + percentage
- Batch import script + handoff guide for team

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zhao
2026-04-24 22:41:57 +09:00
parent 10fa2b74ef
commit 9c09944c96
16 changed files with 990 additions and 127 deletions

View File

@@ -121,10 +121,34 @@ export default function ProcessingBanner() {
{expanded && (
<div className="mt-1.5 flex flex-col gap-1.5" style={{ minWidth: 240 }}>
{processing.map((p) => {
const step = p.processing_step;
const step = p.processing_step || "";
const progress = p.processing_progress || 0;
const total = p.processing_total || 0;
const pct = total > 0 ? Math.round((progress / total) * 100) : 0;
const totalSteps = p.processing_total || 0;
const pct = totalSteps > 0 ? Math.round((progress / totalSteps) * 100) : 0;
// Estimate remaining time based on step
let eta = "";
if (step.includes("Rendering")) {
eta = "~2-3 min";
} else if (step.includes("Reading") || step.includes("Extracting")) {
eta = "~3-5 min";
} else if (step.includes("Matching answer")) {
eta = "~1-2 min";
} else if (step.includes("Generating solution") || step.includes("Generating AI")) {
if (totalSteps > 0 && progress > 0) {
const remaining = totalSteps - progress;
const secsPerBatch = 25;
const batchSize = 3;
const totalSecs = Math.ceil(remaining / batchSize) * secsPerBatch;
if (totalSecs < 60) eta = `~${totalSecs}s`;
else eta = `~${Math.ceil(totalSecs / 60)} min`;
} else {
eta = "~5-8 min";
}
} else if (step) {
eta = "~5-10 min";
}
return (
<div
key={p.id}
@@ -132,18 +156,24 @@ export default function ProcessingBanner() {
>
<div className="flex items-center gap-2.5 mb-1.5">
<span className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin shrink-0" />
<span className="truncate">
<span className="truncate flex-1">
<span className="font-semibold">{p.course_code}</span>{" "}
{p.year} {p.term} {p.exam_type}
</span>
{eta && (
<span className="text-[10px] text-blue-300 shrink-0">{eta}</span>
)}
</div>
{step && (
<>
<div className="text-[10px] text-gray-400 mb-1 truncate">{step}</div>
{total > 0 && (
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full bg-blue-400 rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
</div>
{totalSteps > 0 && (
<>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full bg-blue-400 rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
</div>
<div className="text-[10px] text-gray-500 mt-0.5 text-right">{pct}%</div>
</>
)}
</>
)}

View File

@@ -68,6 +68,122 @@ function renderTex(tex: string, displayMode: boolean): string {
}
}
/**
* Light markdown-to-HTML for raw question text that isn't already HTML.
* Handles fenced code blocks, inline code, markdown tables, and newlines.
*/
function markdownToHtml(text: string): string {
// Split into blocks to handle code fences and tables separately
const blocks: string[] = [];
let remaining = text;
// 1. Extract fenced code blocks first
remaining = remaining.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang: string, code: string) => {
const escaped = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const placeholder = `\x00CODE${blocks.length}\x00`;
blocks.push(`<pre class="bg-gray-900 text-green-300 rounded-lg p-3 text-xs overflow-x-auto my-2 font-mono"><code${lang ? ` class="language-${lang}"` : ""}>${escaped.trimEnd()}</code></pre>`);
return placeholder;
});
// 2. Convert markdown tables
remaining = remaining.replace(
/(?:^|\n)((?:\|[^\n]+\|\n)+\|[-| :]+\|\n(?:\|[^\n]+\|\n?)*)/g,
(_m, table: string) => {
const rows = table.trim().split("\n").filter((r) => r.trim());
if (rows.length < 2) return _m;
const parseRow = (row: string) =>
row.split("|").slice(1, -1).map((c) => c.trim());
const headers = parseRow(rows[0]);
// rows[1] is the separator
const bodyRows = rows.slice(2).map(parseRow);
let html = '<table class="border-collapse text-xs my-2 w-full"><thead><tr>';
for (const h of headers) {
html += `<th class="border border-gray-300 bg-gray-100 px-2 py-1 text-left font-semibold">${h}</th>`;
}
html += "</tr></thead><tbody>";
for (const row of bodyRows) {
html += "<tr>";
for (const cell of row) {
html += `<td class="border border-gray-300 px-2 py-1">${cell}</td>`;
}
html += "</tr>";
}
html += "</tbody></table>";
return html;
}
);
// 3. Inline code: `...` → <code>
remaining = remaining.replace(/`([^`]+)`/g, '<code class="bg-gray-100 text-pink-600 px-1 py-0.5 rounded text-xs font-mono">$1</code>');
// 4. Bold: **...** or __...__
remaining = remaining.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
// 5. Auto-wrap Unicode math symbols in $ if not already wrapped
// Greek letters
remaining = remaining.replace(/(?<!\$)([\u03B1-\u03C9\u0391-\u03A9])(?!\$)/g, (_, ch) => {
const greekMap: Record<string, string> = {
"α": "\\alpha", "β": "\\beta", "γ": "\\gamma", "δ": "\\delta",
"ε": "\\epsilon", "ζ": "\\zeta", "η": "\\eta", "θ": "\\theta",
"λ": "\\lambda", "μ": "\\mu", "ν": "\\nu", "π": "\\pi",
"ρ": "\\rho", "σ": "\\sigma", "τ": "\\tau", "φ": "\\phi",
"χ": "\\chi", "ψ": "\\psi", "ω": "\\omega",
"Σ": "\\Sigma", "Π": "\\Pi", "Δ": "\\Delta", "Ω": "\\Omega",
"Φ": "\\Phi", "Γ": "\\Gamma", "Λ": "\\Lambda", "Θ": "\\Theta",
};
return `$${greekMap[ch] || ch}$`;
});
// Unicode math operators: ≥ ≤ ≠ × ÷ ± ∈ ⊆ ∩ √ ∞
const mathSymbols: [RegExp, string][] = [
[/≥/g, "$\\geq$"], [/≤/g, "$\\leq$"], [/≠/g, "$\\neq$"],
[/×/g, "$\\times$"], [/÷/g, "$\\div$"], [/±/g, "$\\pm$"],
[/∈/g, "$\\in$"], [/∉/g, "$\\notin$"], [/⊆/g, "$\\subseteq$"],
[//g, "$\\cup$"], [/∩/g, "$\\cap$"], [/∅/g, "$\\emptyset$"],
[/√/g, "$\\sqrt{}$"], [/∞/g, "$\\infty$"], [/∑/g, "$\\sum$"],
[/∧/g, "$\\wedge$"], [//g, "$\\vee$"],
];
for (const [re, repl] of mathSymbols) {
remaining = remaining.replace(re, repl);
}
// Unicode superscripts/subscripts
remaining = remaining.replace(/([⁰¹²³⁴⁵⁶⁷⁸⁹ⁿ⁻]+)/g, (_, sups) => {
const supMap: Record<string, string> = {
"⁰": "0", "¹": "1", "²": "2", "³": "3", "⁴": "4",
"⁵": "5", "⁶": "6", "⁷": "7", "⁸": "8", "⁹": "9",
"ⁿ": "n", "⁻": "-",
};
const converted = [...sups].map((c) => supMap[c] || c).join("");
return `$^{${converted}}$`;
});
remaining = remaining.replace(/([₀₁₂₃₄₅₆₇₈₉]+)/g, (_, subs) => {
const subMap: Record<string, string> = {
"₀": "0", "₁": "1", "₂": "2", "₃": "3", "₄": "4",
"₅": "5", "₆": "6", "₇": "7", "₈": "8", "₉": "9",
};
const converted = [...subs].map((c) => subMap[c] || c).join("");
return `$_{${converted}}$`;
});
// Merge adjacent $...$ $...$ → $... ...$
remaining = remaining.replace(/\$\s*\$/g, " ");
// 6. Newlines → <br>
remaining = remaining.replace(/\n/g, "<br>");
// 6. Restore code blocks
for (let i = 0; i < blocks.length; i++) {
remaining = remaining.replace(`\x00CODE${i}\x00`, blocks[i]);
}
return remaining;
}
/**
* Detect if a string is already HTML (has tags) or is raw text.
*/
function isHtml(text: string): boolean {
return /<[a-z][\s\S]*>/i.test(text);
}
export default function KaTeXRenderer({
html,
className,
@@ -75,7 +191,10 @@ export default function KaTeXRenderer({
html: string;
className?: string;
}) {
const rendered = useMemo(() => renderLatexInString(html), [html]);
const rendered = useMemo(() => {
const processed = isHtml(html) ? html : markdownToHtml(html);
return renderLatexInString(processed);
}, [html]);
return (
<div

View File

@@ -90,8 +90,8 @@ export default function UploadForm() {
fd.append("term", term);
fd.append("exam_type", examType);
const result = await uploadPaper(fd);
navigate(`/paper/${result.paper_id}`);
await uploadPaper(fd);
navigate("/");
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
setSubmitting(false);

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { getSimilarQuestions } from "@/lib/api";
import KaTeXRenderer from "@/components/shared/KaTeXRenderer";
import type { Question, SimilarQuestion } from "@/types/api";
const typeLabel: Record<string, string> = {
@@ -21,7 +22,6 @@ function matchColor(percent: number): string {
}
function cleanReason(reason: string): string {
// "Shared topic: foo_bar, baz_qux" → "Shared topic: Foo Bar, Baz Qux"
return reason.replace(/[_]/g, " ").replace(/:\s*(.+)$/, (_, rest) =>
": " + rest.split(",").map((s: string) =>
s.trim().replace(/\b\w/g, (c: string) => c.toUpperCase())
@@ -29,6 +29,108 @@ function cleanReason(reason: string): string {
);
}
function SimilarCard({ item }: { item: SimilarQuestion }) {
const [expanded, setExpanded] = useState(false);
return (
<div
className={`rounded-lg border overflow-hidden transition-all duration-200 ${
expanded ? "border-blue-300 bg-white shadow-sm" : "border-gray-100 hover:border-blue-200 hover:bg-blue-50/40"
}`}
>
{/* Header — click to expand */}
<button
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center gap-2 px-2.5 py-2 text-left"
>
{/* Match % badge */}
<span className={`shrink-0 text-[11px] font-bold px-1.5 py-0.5 rounded ${matchColor(item.match_percent)}`}>
{item.match_percent}%
</span>
{/* Main info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold text-gray-700">{item.source}</span>
<span className="text-xs text-gray-400">·</span>
<span className="text-xs text-gray-500">Q{item.question_number}</span>
{item.question_type && (
<>
<span className="text-xs text-gray-400">·</span>
<span className="text-xs text-gray-500">{typeLabel[item.question_type] ?? item.question_type}</span>
</>
)}
</div>
{/* Topics + reasons */}
<div className="flex gap-1 flex-wrap mt-1">
{item.topics.slice(0, 2).map((topic) => (
<span key={topic} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">
{topic}
</span>
))}
{item.match_reasons
?.filter((r) => !r.startsWith("Same format") && !r.startsWith("Same difficulty"))
.slice(0, 2)
.map((reason) => (
<span key={reason} className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-500">
{cleanReason(reason)}
</span>
))}
</div>
</div>
<span
className={`text-gray-300 text-xs shrink-0 transition-transform duration-200 ${
expanded ? "rotate-90" : ""
}`}
>
</span>
</button>
{/* Expanded preview */}
{expanded && (
<div className="px-3 pb-3 border-t border-gray-100">
<div className="mt-2.5">
<KaTeXRenderer html={item.question_text || ""} className="text-sm text-gray-700 leading-relaxed" />
</div>
{/* All topics */}
{item.topics.length > 0 && (
<div className="flex gap-1 mt-2.5 flex-wrap">
{item.topics.map((t) => (
<span key={t} className="text-[10px] px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 border border-blue-100">
{t}
</span>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 mt-2.5 pt-2.5 border-t border-gray-100">
<Link
to={`/paper/${item.paper_id}`}
className="inline-flex items-center gap-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in Exam
</Link>
<button
onClick={() => setExpanded(false)}
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
Collapse
</button>
</div>
</div>
)}
</div>
);
}
export default function SimilarHistoryPanel({ question }: { question: Question }) {
const [items, setItems] = useState<SimilarQuestion[]>([]);
const [loading, setLoading] = useState(true);
@@ -78,50 +180,7 @@ export default function SimilarHistoryPanel({ question }: { question: Question }
)}
{items.map((item) => (
<Link
key={item.id}
to={`/paper/${item.paper_id}`}
className="flex items-center gap-2 px-2.5 py-2 rounded-lg border border-gray-100 hover:border-blue-200 hover:bg-blue-50/40 transition-colors"
>
{/* Match % badge */}
<span className={`shrink-0 text-[11px] font-bold px-1.5 py-0.5 rounded ${matchColor(item.match_percent)}`}>
{item.match_percent}%
</span>
{/* Main info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold text-gray-700">{item.source}</span>
<span className="text-xs text-gray-400">·</span>
<span className="text-xs text-gray-500">Q{item.question_number}</span>
{item.question_type && (
<>
<span className="text-xs text-gray-400">·</span>
<span className="text-xs text-gray-500">{typeLabel[item.question_type] ?? item.question_type}</span>
</>
)}
</div>
{/* Topics + reasons in one row */}
<div className="flex gap-1 flex-wrap mt-1">
{item.topics.slice(0, 2).map((topic) => (
<span key={topic} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">
{topic}
</span>
))}
{item.match_reasons
?.filter((r) => !r.startsWith("Same format") && !r.startsWith("Same difficulty"))
.slice(0, 2)
.map((reason) => (
<span key={reason} className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-500">
{cleanReason(reason)}
</span>
))}
</div>
</div>
<span className="text-gray-300 text-xs shrink-0"></span>
</Link>
<SimilarCard key={item.id} item={item} />
))}
</div>
)}

View File

@@ -139,9 +139,11 @@ export default function VariantDetail({
<KaTeXRenderer html={variant.ai_hint} />
</CollapsibleSection>
)}
<CollapsibleSection title="Solution" colorScheme="green">
<KaTeXRenderer html={variant.solution} />
</CollapsibleSection>
{variant.solution && (
<CollapsibleSection title="Solution" colorScheme="green">
<KaTeXRenderer html={variant.solution} />
</CollapsibleSection>
)}
</div>
</div>
);

View File

@@ -161,19 +161,21 @@ export default function VariantModal({
)}
</div>
)}
<div>
<button
onClick={() => setShowSolution(!showSolution)}
className="text-sm text-green-600 hover:text-green-800 font-medium"
>
{showSolution ? "▾ Hide Solution" : "▸ Solution"}
</button>
{showSolution && (
<div className="mt-2 bg-green-50 rounded-lg p-3 text-sm border border-green-200">
<KaTeXRenderer html={variant.solution} />
</div>
)}
</div>
{variant.solution && (
<div>
<button
onClick={() => setShowSolution(!showSolution)}
className="text-sm text-green-600 hover:text-green-800 font-medium"
>
{showSolution ? "▾ Hide Solution" : "▸ Solution"}
</button>
{showSolution && (
<div className="mt-2 bg-green-50 rounded-lg p-3 text-sm border border-green-200">
<KaTeXRenderer html={variant.solution} />
</div>
)}
</div>
)}
</div>
<button

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import Header from "@/components/layout/Header";
import KaTeXRenderer from "@/components/shared/KaTeXRenderer";
import { getCourseAnalytics, listCourses } from "@/lib/api";
import type { CourseAnalytics, AnalyticsTopicQuestion } from "@/types/api";
@@ -421,6 +422,7 @@ function InteractiveChart({ topicData, typeData, diffData }: {
// ── Shared components ──
function QuestionCard({ question: q }: { question: QItem }) {
const [expanded, setExpanded] = useState(false);
const typeColor = TYPE_COLORS[q.question_type] ?? "bg-gray-50 text-gray-600 border-gray-200";
const cleanPreview = (q.preview || "")
.replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "")
@@ -430,35 +432,95 @@ function QuestionCard({ question: q }: { question: QItem }) {
.replace(/\s+/g, " ")
.trim();
const fullText = (q.full_text || q.preview || "")
.replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "")
.trim();
return (
<Link to={`/paper/${q.paper_id}`}
className="flex items-start gap-3 bg-gray-50 border border-gray-200 border-l-2 border-l-transparent rounded-xl px-3.5 py-2.5 hover:border-blue-300 hover:border-l-blue-500 hover:bg-white hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 group">
<span className="shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600 text-white text-xs font-bold mt-0.5">
{q.question_number}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1 flex-wrap">
<span className="text-xs font-medium text-blue-600">{q.source}</span>
<span className="text-gray-300">·</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border font-medium ${typeColor}`}>
{typeLabel[q.question_type] ?? q.question_type}
</span>
{q.difficulty && (
<>
<span className="text-gray-300">·</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border font-medium ${DIFF_COLORS[q.difficulty] ?? ""}`}>
{q.difficulty}
</span>
</>
<div
className={`bg-gray-50 border border-gray-200 border-l-2 rounded-xl overflow-hidden transition-all duration-200 ${
expanded ? "border-l-blue-500 border-blue-200 bg-white shadow-md" : "border-l-transparent hover:border-blue-300 hover:border-l-blue-500 hover:bg-white hover:shadow-md hover:-translate-y-0.5"
}`}
>
{/* Header — click to expand */}
<button
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-start gap-3 px-3.5 py-2.5 text-left group"
>
<span className="shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600 text-white text-xs font-bold mt-0.5">
{q.question_number}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1 flex-wrap">
<span className="text-xs font-medium text-blue-600">{q.source}</span>
<span className="text-gray-300">·</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border font-medium ${typeColor}`}>
{typeLabel[q.question_type] ?? q.question_type}
</span>
{q.difficulty && (
<>
<span className="text-gray-300">·</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border font-medium ${DIFF_COLORS[q.difficulty] ?? ""}`}>
{q.difficulty}
</span>
</>
)}
{q.topics?.slice(0, 2).map((t) => (
<span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 border border-gray-200">{t}</span>
))}
</div>
{!expanded && (
<p className="text-xs text-gray-600 line-clamp-2 leading-relaxed">{cleanPreview || q.preview}</p>
)}
{q.topics?.slice(0, 2).map((t) => (
<span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 border border-gray-200">{t}</span>
))}
</div>
<p className="text-xs text-gray-600 line-clamp-2 leading-relaxed">{cleanPreview || q.preview}</p>
</div>
<span className="shrink-0 text-gray-300 group-hover:text-blue-500 text-sm pt-1"></span>
</Link>
<span
className={`shrink-0 text-gray-300 group-hover:text-blue-500 text-sm pt-1 transition-transform duration-200 ${
expanded ? "rotate-90" : ""
}`}
>
</span>
</button>
{/* Expanded preview */}
{expanded && (
<div className="px-3.5 pb-3 animate-[fadeIn_0.2s_ease-out]">
<div className="ml-11 border-t border-gray-100 pt-3">
<KaTeXRenderer html={fullText} className="text-sm text-gray-700 leading-relaxed" />
{/* All topics */}
{q.topics && q.topics.length > 0 && (
<div className="flex gap-1 mt-3 flex-wrap">
{q.topics.map((t) => (
<span key={t} className="text-[10px] px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 border border-blue-100">
{t}
</span>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-gray-100">
<Link
to={`/paper/${q.paper_id}`}
className="inline-flex items-center gap-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-700 px-3.5 py-1.5 rounded-lg transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in Exam
</Link>
<button
onClick={() => setExpanded(false)}
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
Collapse
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -172,7 +172,9 @@ export default function ErrorBookPage() {
<span className="text-yellow-400"></span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-700">Variant of Q{v.source_question_number}</span>
<p className="text-xs text-gray-500 truncate">{v.variant_data.question_text?.replace(/<[^>]*>/g, "").slice(0, 100)}</p>
<div className="text-xs text-gray-500 line-clamp-2">
<KaTeXRenderer html={v.variant_data.question_text || ""} className="text-xs" />
</div>
</div>
<button onClick={() => void handleUnfavoriteVariant(v.id)} className="text-xs text-gray-400 hover:text-red-500">Remove</button>
</div>
@@ -264,8 +266,10 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
)}
</div>
{/* Question preview */}
<p className="text-sm text-gray-600 mt-3 line-clamp-2">{preview}</p>
{/* Question text */}
<div className="mt-3">
<KaTeXRenderer html={question.question_text || ""} className="text-sm text-gray-600 leading-relaxed" />
</div>
{/* Topics */}
{question.topics && question.topics.length > 0 && (

View File

@@ -461,9 +461,9 @@ export default function WorkbenchPage() {
</button>
</div>
</div>
<p className="text-xs text-gray-600 line-clamp-2 mb-3">
{v.variant_data.question_text?.replace(/<[^>]*>/g, "").slice(0, 140)}
</p>
<div className="text-xs text-gray-600 line-clamp-3 mb-3">
<KaTeXRenderer html={v.variant_data.question_text || ""} className="text-xs" />
</div>
<button
onClick={() => setActiveVariantId(v.id)}
className="px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700"

View File

@@ -130,6 +130,7 @@ export interface AnalyticsTopicQuestion {
source: string;
question_number: string;
preview: string;
full_text?: string;
difficulty: string | null;
question_type: string;
year?: number | null;