Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1503,6 +1503,20 @@ Packaging and scripts:
OpenRouter `error.metadata.raw` upstream errors without retaining free-form
provider messages. Keep these fields in the debug-console allowlist; never
restore raw response bodies to exceptions, logs, or support bundles.
Retry confirmed HTTP 429 responses at most twice, within a five-second
retry-admission window and the original overall request deadline. Respect
`Retry-After` seconds and HTTP dates; do not shorten a server wait to fit that
window. Without a valid hint, use bounded exponential backoff with jitter.
Retain the original audio and rebuild each JSON body because aiohttp closes
uploaded streams. Non-seekable audio, explicit credit/spending failures,
other HTTP errors, ambiguous response timeouts, and cancellation must never
trigger an automatic replay. HTTP 429 alone does not establish missing
OpenRouter credit; keep its temporary rate-limit message distinct from 402.
- Failed Live Mic records persist their safe provider message in `step`, with
only recognized speech in transcript content. Preserve partial speech and
empty failed history entries. Transcript Detail shows the failure separately
and excludes legacy error-only content from word counts and copy/export
actions; never add an error string as a transcript segment.
- Meta language-model choices are `muse-spark-1.3` and
`muse-spark-1.3-contributor`; exact persisted 1.2 built-in selections migrate
to their matching 1.3 tier without rewriting custom model codes. This also
Expand Down
7 changes: 7 additions & 0 deletions Frontend/client/src/i18n/translations/de/transcription.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { TranslationCatalog } from "@/i18n/types";

export const transcriptionTranslations = {
"Waiting before retrying transcription...": "Kurze Wartezeit vor dem nächsten Transkriptionsversuch…",
"Microsoft MAI Transcribe via OpenRouter is temporarily rate limited (HTTP 429). Wait briefly or switch transcription provider.":
"Microsoft MAI Transcribe über OpenRouter ist vorübergehend ausgelastet oder durch ein Ratenlimit begrenzt (HTTP 429). Warte kurz oder wechsle den Transkriptionsanbieter.",
"Microsoft MAI Transcribe via OpenRouter reports insufficient credits or a spending limit (HTTP 402). Check the provider billing limits.":
"Microsoft MAI Transcribe über OpenRouter meldet fehlendes Guthaben oder ein Ausgabenlimit (HTTP 402). Prüfe die Abrechnungslimits beim Anbieter.",
"Microsoft MAI Transcribe via OpenRouter reports insufficient credits or a spending limit (HTTP 429). Check the provider billing limits.":
"Microsoft MAI Transcribe über OpenRouter meldet fehlendes Guthaben oder ein Ausgabenlimit (HTTP 429). Prüfe die Abrechnungslimits beim Anbieter.",
"Preparing next recording": "Nächste Aufnahme wird vorbereitet",
"Starts as soon as the microphone is ready": "Startet, sobald das Mikrofon bereit ist",
"You can start your next recording now": "Du kannst jetzt die nächste Aufnahme starten",
Expand Down
4 changes: 2 additions & 2 deletions Frontend/client/src/lib/request-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const TIMEOUT_ERROR_TOKENS = ["timeout", "timed out", "aborted", "aborterror"];
const CORS_ERROR_TOKENS = ["cors", "cross-origin"];
const INVALID_ARGUMENT_TOKENS = ["errno 22", "invalid argument"];

const PREFIX_PATTERN = /^\[(error|timeout|download error)\]\s*/i;
const PREFIX_PATTERN = /^\[(error|timeout|download error|storage error)\]\s*/i;
const STATUS_PREFIX_PATTERN = /^\d{3}:\s*/;

function stripLowLevelPrefixes(rawMessage: string): string {
Expand Down Expand Up @@ -89,7 +89,7 @@ export async function responseErrorMessage(res: Response): Promise<string> {
export function extractFailureMessage(content: string, step: string): string {
const rawContent = (content || "").trim();
if (rawContent) {
const matches = Array.from(rawContent.matchAll(/\[(error|timeout|download error)\]\s*([^\n]+)/gi));
const matches = Array.from(rawContent.matchAll(/\[(error|timeout|download error|storage error)\]\s*([^\n]+)/gi));
if (matches.length > 0) {
const last = matches[matches.length - 1];
const reason = (last?.[2] || "").trim();
Expand Down
87 changes: 87 additions & 0 deletions Frontend/client/src/pages/TranscriptDetail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Route, Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { LANGUAGE_STORAGE_KEY, LocaleProvider } from "@/i18n";
import type { TranscriptDetailResponse } from "@/lib/api-types";
import TranscriptDetail from "./TranscriptDetail";
import { AppScrollContainerContext } from "@/contexts/AppScrollContainerContext";

vi.mock("@/hooks/use-transcript-auto-refresh", () => ({ useTranscriptAutoRefresh: () => ({ isWsConnected: true }) }));
vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false }));

const rateLimitMessage =
"Microsoft MAI Transcribe via OpenRouter is temporarily rate limited (HTTP 429). Wait briefly or switch transcription provider.";

function mount(content = "", step = rateLimitMessage, summary = "", type: TranscriptDetailResponse["type"] = "mic") {
const record: TranscriptDetailResponse = {
id: "failed-mic",
title: "Live Mic",
date: "Today",
duration: "00:03",
type,
status: "failed",
content,
step,
summary,
};
const client = new QueryClient({ defaultOptions: { queries: { retry: false, queryFn: async () => record } } });
const location = memoryLocation({ path: "/transcript/failed-mic" });
return render(
<QueryClientProvider client={client}>
<LocaleProvider>
<Router hook={location.hook}>
<AppScrollContainerContext.Provider value={{ current: null }}>
<Route path="/transcript/:id">
<TranscriptDetail />
</Route>
</AppScrollContainerContext.Provider>
</Router>
</LocaleProvider>
</QueryClientProvider>,
);
}

describe("failed transcript", () => {
beforeEach(() => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "en");
});

it("shows the persisted failure separately from transcript text and actions", async () => {
mount();
expect(await screen.findByRole("alert")).toHaveTextContent(rateLimitMessage);
expect(screen.getByText("No transcript text captured.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Copy transcript" })).toBeNull();
expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
expect(screen.queryByText(/\d+ words/)).toBeNull();
});

it("copies and counts only retained speech when a recording fails", async () => {
const user = userEvent.setup();
mount("Retained speech.");
expect(await screen.findByRole("alert")).toHaveTextContent(rateLimitMessage);
expect(screen.getByText("2 words")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Copy transcript" }));
expect(await navigator.clipboard.readText()).toBe("Retained speech.");
expect(screen.getByRole("button", { name: "Export" })).toBeEnabled();
});

it.each([
["Error", "mic"],
["Timeout", "mic"],
["Download error", "youtube"],
["Storage error", "file"],
["Storage error", "youtube"],
] as const)("excludes legacy %s content from a %s transcript", async (prefix, type) => {
mount(`[${prefix}] ${rateLimitMessage}`, "Transcribing...", "Earlier summary", type);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(rateLimitMessage);
expect(alert).not.toHaveTextContent(`[${prefix}]`);
expect(screen.queryByText(/\d+ words/)).toBeNull();
expect(screen.queryByRole("button", { name: "Copy transcript" })).toBeNull();
expect(screen.getByText("No transcript text captured.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
});
});
86 changes: 50 additions & 36 deletions Frontend/client/src/pages/TranscriptDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -437,13 +437,15 @@ export default function TranscriptDetail() {
const summaryMarkdown = useMemo(() => normalizeSummaryMarkdown(summarySource), [summarySource]);
const summaryCopyText = summaryFormat === "html" ? preparedSummaryHtml.plainText : summaryMarkdown;
const hasSummary = summarySource.length > 0;
const isFailedTranscript = transcript.status === "failed";
const failedContentLooksLikeErrorOnly =
isFailedTranscript &&
/^\[(error|timeout|download error|storage error)\]/i.test(String(transcript.content || "").trim());
const transcriptContent = failedContentLooksLikeErrorOnly ? "" : String(transcript.content || "");
const hasExportableContent = !failedContentLooksLikeErrorOnly && Boolean(transcriptContent.trim() || hasSummary);
const transcriptWordCount = useMemo(
() =>
String(transcript.content || "")
.trim()
.split(/\s+/)
.filter(Boolean).length,
[transcript.content],
() => transcriptContent.trim().split(/\s+/).filter(Boolean).length,
[transcriptContent],
);
const summaryStatus = String(transcript?.summaryStatus || (hasSummary ? "completed" : "idle")).toLowerCase();
const summaryStepLower = String(transcript?.step || "").toLowerCase();
Expand All @@ -462,24 +464,22 @@ export default function TranscriptDetail() {
transcript.status === "completed" && !hasSummary && !isSummaryInProgress && !isSummaryFailed;
const isFailedYoutubeTranscript = transcript?.status === "failed" && transcript?.type === "youtube";
const rawFailureMessage = useMemo(
() => extractFailureMessage(String(transcript?.content || ""), String(transcript?.step || "")),
[transcript?.content, transcript?.step],
() =>
transcript.type === "mic" && !failedContentLooksLikeErrorOnly
? String(transcript.step || "")
: extractFailureMessage(String(transcript.content || ""), String(transcript.step || "")),
[failedContentLooksLikeErrorOnly, transcript.content, transcript.step, transcript.type],
);
const failedMessage = useMemo(
() => (isFailedYoutubeTranscript ? t(friendlyRequestMessage(rawFailureMessage, t("Transcription failed."))) : ""),
[isFailedYoutubeTranscript, rawFailureMessage, t],
() => (isFailedTranscript ? t(friendlyRequestMessage(rawFailureMessage, t("Transcription failed."))) : ""),
[isFailedTranscript, rawFailureMessage, t],
);
const technicalFailureMessage = useMemo(() => {
if (!isFailedYoutubeTranscript) return "";
const technical = (rawFailureMessage || "").trim();
if (!technical || technical === failedMessage) return "";
return technical;
}, [failedMessage, isFailedYoutubeTranscript, rawFailureMessage]);
const failedContentLooksLikeErrorOnly = useMemo(() => {
if (!isFailedYoutubeTranscript) return false;
const content = String(transcript?.content || "").trim();
return /^\[(error|timeout|download error)\]/i.test(content);
}, [isFailedYoutubeTranscript, transcript?.content]);

const retryYoutubeTranscription = useCallback(async () => {
if (!id || isRetryingYoutube) return;
Expand Down Expand Up @@ -568,7 +568,7 @@ export default function TranscriptDetail() {
if (!navigator.clipboard?.writeText) {
throw new Error(t("Clipboard API unavailable"));
}
await navigator.clipboard.writeText(transcript?.content || "");
await navigator.clipboard.writeText(transcriptContent);
setCopied(true);
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current);
Expand All @@ -587,7 +587,7 @@ export default function TranscriptDetail() {
variant: "destructive",
});
}
}, [t, toast, transcript?.content]);
}, [t, toast, transcriptContent]);

const handleCopySummary = useCallback(async () => {
try {
Expand Down Expand Up @@ -617,7 +617,7 @@ export default function TranscriptDetail() {

const handleExport = useCallback(
async (format: "pdf" | "docx") => {
if (!id || isExporting) return;
if (!id || isExporting || !hasExportableContent) return;
setIsExporting(true);
try {
const result = await saveTranscriptExport(
Expand All @@ -642,7 +642,7 @@ export default function TranscriptDetail() {
setIsExporting(false);
}
},
[id, isExporting, t, toast, transcript?.title],
[hasExportableContent, id, isExporting, t, toast, transcript?.title],
);

const getBackLink = () => {
Expand Down Expand Up @@ -720,7 +720,7 @@ export default function TranscriptDetail() {
</a>
</Button>
)}
{transcript.content && (
{transcriptContent && (
<Button
variant="default"
size="sm"
Expand Down Expand Up @@ -751,21 +751,28 @@ export default function TranscriptDetail() {
size="sm"
className="hidden md:flex data-[state=open]:bg-accent"
style={{ transform: "none" }}
disabled={!hasExportableContent}
type="button"
>
<Download className="w-4 h-4 mr-2" /> {t("Export")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={isExporting} onSelect={() => void handleExport("pdf")}>
<DropdownMenuItem
disabled={isExporting || !hasExportableContent}
onSelect={() => void handleExport("pdf")}
>
{isExporting ? (
<WavePhysicsLoader className="mr-2" size="inline" />
) : (
<FileText className="w-4 h-4 mr-2" />
)}{" "}
{t("Export as PDF")}
</DropdownMenuItem>
<DropdownMenuItem disabled={isExporting} onSelect={() => void handleExport("docx")}>
<DropdownMenuItem
disabled={isExporting || !hasExportableContent}
onSelect={() => void handleExport("docx")}
>
{isExporting ? (
<WavePhysicsLoader className="mr-2" size="inline" />
) : (
Expand Down Expand Up @@ -807,7 +814,7 @@ export default function TranscriptDetail() {
</a>
</DropdownMenuItem>
)}
{transcript.content && (
{transcriptContent && (
<DropdownMenuItem onClick={handleCopyTranscript}>
<Copy className="w-4 h-4 mr-2" /> {t("Copy transcript")}
</DropdownMenuItem>
Expand All @@ -817,15 +824,21 @@ export default function TranscriptDetail() {
<Copy className="w-4 h-4 mr-2" /> {t("Copy summary")}
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={isExporting} onSelect={() => void handleExport("pdf")}>
<DropdownMenuItem
disabled={isExporting || !hasExportableContent}
onSelect={() => void handleExport("pdf")}
>
{isExporting ? (
<WavePhysicsLoader className="mr-2" size="inline" />
) : (
<FileText className="w-4 h-4 mr-2" />
)}{" "}
{t("Export as PDF")}
</DropdownMenuItem>
<DropdownMenuItem disabled={isExporting} onSelect={() => void handleExport("docx")}>
<DropdownMenuItem
disabled={isExporting || !hasExportableContent}
onSelect={() => void handleExport("docx")}
>
{isExporting ? (
<WavePhysicsLoader className="mr-2" size="inline" />
) : (
Expand Down Expand Up @@ -896,14 +909,19 @@ export default function TranscriptDetail() {
</div>
)}

{isFailedYoutubeTranscript && (
{isFailedTranscript && (
<div className="space-y-2">
<QueryErrorState
title={t("YouTube transcription failed")}
title={t(isFailedYoutubeTranscript ? "YouTube transcription failed" : "Transcription failed")}
className="text-foreground [&>svg]:text-red-500 dark:[&>svg]:text-red-400"
description={failedMessage || t("The transcription failed. Please try again.")}
onRetry={() => {
void retryYoutubeTranscription();
}}
onRetry={
isFailedYoutubeTranscript
? () => {
void retryYoutubeTranscription();
}
: undefined
}
/>
{technicalFailureMessage && (
<p className="text-xs text-muted-foreground px-1">
Expand Down Expand Up @@ -1007,12 +1025,8 @@ export default function TranscriptDetail() {
t("Loading…")
) : transcript.status === "processing" ? (
<span className="text-muted-foreground italic"></span>
) : isFailedYoutubeTranscript && failedContentLooksLikeErrorOnly ? (
<span className="text-muted-foreground italic">
{failedMessage || t("No transcript text captured.")}
</span>
) : transcript.content ? (
<SpeakerFormattedText content={transcript.content} />
) : transcriptContent ? (
<SpeakerFormattedText content={transcriptContent} />
) : (
t("No transcript text captured.")
)}
Expand Down
4 changes: 2 additions & 2 deletions Frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "scriber",
"version": "0.5.127",
"version": "0.5.128",
"type": "module",
"license": "MIT",
"engines": {
Expand Down
Loading
Loading