mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-24 07:58:05 +00:00
feat: add video transcripts workflow (2026-05-10)
This commit is contained in:
@@ -15,6 +15,7 @@ ATLAS Terminal brings market overview, quant research, valuation, technical anal
|
|||||||
- Technical analysis with candlesticks, moving averages, Bollinger Bands, RSI, MACD, and Fibonacci levels
|
- Technical analysis with candlesticks, moving averages, Bollinger Bands, RSI, MACD, and Fibonacci levels
|
||||||
- Cross-market monitoring through macro, smart-money, yield/FX, economic calendar, earnings, news, filings, and portfolio pages
|
- Cross-market monitoring through macro, smart-money, yield/FX, economic calendar, earnings, news, filings, and portfolio pages
|
||||||
- Portfolio tooling for OCR import, cross-asset correlation, and a UK CGT planning calculator
|
- Portfolio tooling for OCR import, cross-asset correlation, and a UK CGT planning calculator
|
||||||
|
- Video transcript ingestion for YouTube, direct media URLs, and local uploads with searchable storage and optional Korean translation
|
||||||
- Institutional report generation with printable PDF-style layouts
|
- Institutional report generation with printable PDF-style layouts
|
||||||
|
|
||||||
## Core Product Principle
|
## Core Product Principle
|
||||||
@@ -67,6 +68,7 @@ You can also click the screenshot below to open the recorded walkthrough:
|
|||||||
- `/valuation` DCF and scenario analysis
|
- `/valuation` DCF and scenario analysis
|
||||||
- `/technical` chart-driven technical analysis
|
- `/technical` chart-driven technical analysis
|
||||||
- `/macro` macro and smart-money dashboard
|
- `/macro` macro and smart-money dashboard
|
||||||
|
- `/transcripts` video and audio transcript workbench
|
||||||
- `/filings` SEC, DART, and EDINET workflows
|
- `/filings` SEC, DART, and EDINET workflows
|
||||||
- `/report` institutional report generator
|
- `/report` institutional report generator
|
||||||
- `/portfolio` portfolio tracking and OCR import
|
- `/portfolio` portfolio tracking and OCR import
|
||||||
@@ -177,3 +179,29 @@ Credential API:
|
|||||||
ATLAS Terminal started as an attempt to build a personal Bloomberg-lite for retail investing workflows: high information density, clean narrative structure, and a hard separation between AI-generated language and deterministic financial computation.
|
ATLAS Terminal started as an attempt to build a personal Bloomberg-lite for retail investing workflows: high information density, clean narrative structure, and a hard separation between AI-generated language and deterministic financial computation.
|
||||||
|
|
||||||
It is currently optimized as a desktop-first personal research environment rather than a SaaS product.
|
It is currently optimized as a desktop-first personal research environment rather than a SaaS product.
|
||||||
|
|
||||||
|
## Video Transcript Prerequisite
|
||||||
|
|
||||||
|
The `/transcripts` workflow uses `faster-whisper` plus system `ffmpeg` for local speech-to-text and media conversion. On macOS, install `ffmpeg` before running transcript jobs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
If Homebrew is not available, the repo also includes an `imageio-ffmpeg` fallback so local media extraction can still run in lightweight environments.
|
||||||
|
|
||||||
|
`faster-whisper` downloads its first model automatically on demand. The default is `base`, and you can override it with `WHISPER_MODEL_SIZE=small` or `WHISPER_MODEL_SIZE=medium`.
|
||||||
|
|
||||||
|
## Transcript Workflow
|
||||||
|
|
||||||
|
Updated May 10, 2026.
|
||||||
|
|
||||||
|
The new `/transcripts` route adds a dedicated ingestion and review flow for long-form media research:
|
||||||
|
|
||||||
|
- Submit a YouTube URL, direct media URL, or local file upload
|
||||||
|
- Prefer subtitle extraction first, then fall back to local Whisper transcription
|
||||||
|
- Persist transcript text, summary, keywords, topics, and intent in the app database
|
||||||
|
- Search saved transcripts with full-text search across completed jobs
|
||||||
|
- Translate completed transcripts into Korean on demand when a Gemini key is configured in Settings
|
||||||
|
|
||||||
|
This makes it easier to turn interviews, news clips, and earnings-related video into structured research notes inside ATLAS without leaving the terminal workflow.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { BarChart3, Briefcase, CalendarDays, CalendarRange, FileSearch, FileText, Globe, Landmark, LineChart, Microscope, Newspaper, Search, Settings, Sparkles, Target, TrendingUp } from "lucide-react";
|
import { BarChart3, Briefcase, CalendarDays, CalendarRange, FileSearch, FileText, FileVideo, Globe, Landmark, LineChart, Microscope, Newspaper, Search, Settings, Sparkles, Target, TrendingUp } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { flags } from "../lib/flags";
|
import { flags } from "../lib/flags";
|
||||||
import { searchTickerSuggestions, type TickerSuggestion } from "../lib/ticker-alias";
|
import { searchTickerSuggestions, type TickerSuggestion } from "../lib/ticker-alias";
|
||||||
@@ -17,6 +17,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/earnings", label: "Earnings", icon: CalendarRange },
|
{ href: "/earnings", label: "Earnings", icon: CalendarRange },
|
||||||
...(flags.calendar ? [{ href: "/calendar", label: "Calendar", icon: CalendarDays }] : []),
|
...(flags.calendar ? [{ href: "/calendar", label: "Calendar", icon: CalendarDays }] : []),
|
||||||
{ href: "/news", label: "News", icon: Newspaper },
|
{ href: "/news", label: "News", icon: Newspaper },
|
||||||
|
{ href: "/transcripts", label: "Transcripts", icon: FileVideo },
|
||||||
{ href: "/screener", label: "Screener", icon: Target },
|
{ href: "/screener", label: "Screener", icon: Target },
|
||||||
{ href: "/portfolio", label: "Portfolio", icon: Briefcase },
|
{ href: "/portfolio", label: "Portfolio", icon: Briefcase },
|
||||||
{ href: "/filings", label: "Filings", icon: FileSearch },
|
{ href: "/filings", label: "Filings", icon: FileSearch },
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Check, Copy, Languages, Search } from "lucide-react";
|
||||||
|
import { useDeferredValue, useMemo, useState } from "react";
|
||||||
|
import { Card } from "../ui/Card";
|
||||||
|
import { ErrorBanner } from "../ui/ErrorBanner";
|
||||||
|
import { LoadingPulse } from "../ui/LoadingPulse";
|
||||||
|
import type { VideoJobDetailResponse, VideoTranslation } from "../../lib/video-transcript-types";
|
||||||
|
|
||||||
|
interface TranscriptDetailPanelProps {
|
||||||
|
detail: VideoJobDetailResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
translation: VideoTranslation | null;
|
||||||
|
translating: boolean;
|
||||||
|
onTranslate: (jobId: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentimentTone(sentiment: string | null | undefined) {
|
||||||
|
if (sentiment === "positive") return "border-fin-positive/40 bg-fin-positive/10 text-fin-positive";
|
||||||
|
if (sentiment === "negative") return "border-fin-negative/40 bg-fin-negative/10 text-fin-negative";
|
||||||
|
return "border-border bg-surface-sunken text-text-secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightText(text: string, query: string) {
|
||||||
|
if (!query.trim()) return text;
|
||||||
|
const matcher = new RegExp(`(${escapeRegExp(query)})`, "ig");
|
||||||
|
const lowered = query.toLowerCase();
|
||||||
|
return text.split(matcher).map((part, index) => (
|
||||||
|
part.toLowerCase() === lowered ? (
|
||||||
|
<mark key={`${part}-${index}`} className="rounded bg-brand-gold/30 px-0.5 text-brand-navy">
|
||||||
|
{part}
|
||||||
|
</mark>
|
||||||
|
) : (
|
||||||
|
<span key={`${part}-${index}`}>{part}</span>
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TranscriptDetailPanel({
|
||||||
|
detail,
|
||||||
|
loading,
|
||||||
|
translation,
|
||||||
|
translating,
|
||||||
|
onTranslate,
|
||||||
|
}: TranscriptDetailPanelProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [showTranslation, setShowTranslation] = useState(false);
|
||||||
|
const deferredQuery = useDeferredValue(query);
|
||||||
|
const transcriptText = showTranslation ? (translation?.text || "") : (detail?.transcript?.text || "");
|
||||||
|
const highlightedTranscript = useMemo(
|
||||||
|
() => highlightText(transcriptText, deferredQuery),
|
||||||
|
[deferredQuery, transcriptText],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
if (!transcriptText) return;
|
||||||
|
await navigator.clipboard.writeText(transcriptText);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1600);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !detail) {
|
||||||
|
return <LoadingPulse label="Loading transcript detail..." height="h-[560px]" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
return (
|
||||||
|
<Card title="Transcript Detail" subtitle="Choose a job to inspect its summary, keywords, and transcript text.">
|
||||||
|
<div className="rounded-md border border-dashed border-border-strong bg-bg-primary px-4 py-16 text-center text-sm text-text-muted">
|
||||||
|
Select a transcript job from the left column or search your saved transcripts above.
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { job, transcript } = detail;
|
||||||
|
const displaySummary = showTranslation ? (translation?.summary || transcript?.summary) : transcript?.summary;
|
||||||
|
const displayKeywords = showTranslation ? (translation?.keywords || transcript?.keywords || []) : (transcript?.keywords || []);
|
||||||
|
const displayTopics = showTranslation ? (translation?.topics || transcript?.topics || []) : (transcript?.topics || []);
|
||||||
|
const displayIntent = showTranslation ? (translation?.intent || transcript?.intent) : transcript?.intent;
|
||||||
|
|
||||||
|
async function handleTranslateToggle() {
|
||||||
|
if (!transcript) return;
|
||||||
|
if (showTranslation) {
|
||||||
|
setShowTranslation(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!translation) {
|
||||||
|
await onTranslate(job.job_id);
|
||||||
|
}
|
||||||
|
setShowTranslation(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card
|
||||||
|
title={job.title || "Transcript Detail"}
|
||||||
|
subtitle={`${job.source_type.toUpperCase()} • ${job.language?.toUpperCase() || "auto"} • ${job.duration_sec != null ? `${job.duration_sec}s` : "duration pending"}`}
|
||||||
|
action={transcript ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleTranslateToggle()}
|
||||||
|
disabled={translating}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-semibold text-brand-navy transition-colors hover:bg-surface-sunken disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<Languages className="h-4 w-4" />
|
||||||
|
{showTranslation ? "원문 보기" : translating ? "번역 중..." : "한국어 번역"}
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${sentimentTone(job.status === "failed" ? "negative" : transcript?.sentiment)}`}>
|
||||||
|
{job.status === "failed" ? "Failed" : `Sentiment: ${transcript?.sentiment || "pending"}`}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-border bg-surface-sunken px-3 py-1 text-xs font-mono text-text-secondary">
|
||||||
|
{job.status.toUpperCase()} · {job.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ErrorBanner variant="error" message={job.error} />
|
||||||
|
<ErrorBanner
|
||||||
|
variant="info"
|
||||||
|
message={job.status !== "completed" && job.status !== "failed" ? "This job is still processing. The panel will refresh every 5 seconds." : null}
|
||||||
|
/>
|
||||||
|
<ErrorBanner
|
||||||
|
variant="info"
|
||||||
|
message={showTranslation ? "한국어 번역본을 보고 있습니다. 필요하면 다시 눌러 원문으로 돌아갈 수 있습니다." : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(260px,0.8fr)]">
|
||||||
|
<section className="rounded-md border border-border bg-bg-primary p-4">
|
||||||
|
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-text-secondary">Summary</div>
|
||||||
|
<p className="text-sm leading-7 text-text-primary">
|
||||||
|
{displaySummary || "Summary will appear here once transcript extraction and analysis complete."}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 rounded-md border border-border bg-surface-raised p-4">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-text-secondary">Keywords</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{displayKeywords.map((keyword) => (
|
||||||
|
<span key={keyword} className="rounded-full border border-brand-blue/30 bg-brand-blue/10 px-2.5 py-1 text-xs font-semibold text-brand-blue">
|
||||||
|
#{keyword}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{displayKeywords.length === 0 && (
|
||||||
|
<span className="text-sm text-text-muted">Keywords pending.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-text-secondary">Topics</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{displayTopics.map((topic) => (
|
||||||
|
<span key={topic} className="rounded-full border border-brand-gold/40 bg-brand-gold/10 px-2.5 py-1 text-xs font-semibold text-brand-navy">
|
||||||
|
{topic}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{displayTopics.length === 0 && (
|
||||||
|
<span className="text-sm text-text-muted">Topics pending.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-text-secondary">Intent</div>
|
||||||
|
<p className="text-sm leading-6 text-text-secondary">
|
||||||
|
{displayIntent || "Intent analysis pending."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Full Transcript"
|
||||||
|
subtitle="Search within the extracted text, then copy the full transcript for downstream research."
|
||||||
|
action={(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-semibold text-brand-navy transition-colors hover:bg-surface-sunken"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-4 w-4 text-fin-positive" /> : <Copy className="h-4 w-4" />}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-2">
|
||||||
|
<Search className="h-4 w-4 text-text-muted" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Search inside this transcript..."
|
||||||
|
className="w-full bg-transparent text-sm text-text-primary outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{transcript ? (
|
||||||
|
<div className="max-h-[72vh] overflow-auto rounded-md border border-border bg-bg-primary p-4 font-mono text-sm leading-7 text-text-primary whitespace-pre-wrap">
|
||||||
|
{highlightedTranscript}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-border-strong bg-bg-primary px-4 py-16 text-center text-sm text-text-muted">
|
||||||
|
Transcript text will appear here once extraction completes.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangle, CheckCircle2, Clock3, LoaderCircle, Trash2 } from "lucide-react";
|
||||||
|
import { Card } from "../ui/Card";
|
||||||
|
import { LoadingPulse } from "../ui/LoadingPulse";
|
||||||
|
import type { VideoJob, VideoJobStatus } from "../../lib/video-transcript-types";
|
||||||
|
|
||||||
|
interface TranscriptJobListProps {
|
||||||
|
jobs: VideoJob[];
|
||||||
|
loading: boolean;
|
||||||
|
selectedJobId: string | null;
|
||||||
|
onSelectJob: (jobId: string) => void;
|
||||||
|
onDeleteJob: (jobId: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<VideoJobStatus, string> = {
|
||||||
|
queued: "Queued",
|
||||||
|
fetching: "Fetching",
|
||||||
|
transcribing: "Transcribing",
|
||||||
|
analyzing: "Analyzing",
|
||||||
|
completed: "Completed",
|
||||||
|
failed: "Failed",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TONES: Record<VideoJobStatus, string> = {
|
||||||
|
queued: "border-border bg-surface-sunken text-text-secondary",
|
||||||
|
fetching: "border-brand-gold/40 bg-brand-gold/10 text-brand-navy",
|
||||||
|
transcribing: "border-brand-blue/40 bg-brand-blue/10 text-brand-blue",
|
||||||
|
analyzing: "border-brand-gold/40 bg-brand-gold/10 text-brand-navy",
|
||||||
|
completed: "border-fin-positive/40 bg-fin-positive/10 text-fin-positive",
|
||||||
|
failed: "border-fin-negative/40 bg-fin-negative/10 text-fin-negative",
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusIcon({ status }: { status: VideoJobStatus }) {
|
||||||
|
if (status === "completed") return <CheckCircle2 className="h-4 w-4" />;
|
||||||
|
if (status === "failed") return <AlertTriangle className="h-4 w-4" />;
|
||||||
|
if (status === "queued") return <Clock3 className="h-4 w-4" />;
|
||||||
|
return <LoaderCircle className="h-4 w-4 animate-spin" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TranscriptJobList({
|
||||||
|
jobs,
|
||||||
|
loading,
|
||||||
|
selectedJobId,
|
||||||
|
onSelectJob,
|
||||||
|
onDeleteJob,
|
||||||
|
}: TranscriptJobListProps) {
|
||||||
|
return (
|
||||||
|
<Card title="Recent Jobs" subtitle="Select a job to inspect progress, summary, and full transcript.">
|
||||||
|
{loading && jobs.length === 0 ? (
|
||||||
|
<LoadingPulse label="Loading transcript jobs..." height="h-48" />
|
||||||
|
) : jobs.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border-strong bg-bg-primary px-4 py-10 text-center text-sm text-text-muted">
|
||||||
|
No transcript jobs yet. Submit a video, podcast clip, or subtitle file to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{jobs.map((job) => {
|
||||||
|
const active = selectedJobId === job.job_id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={job.job_id}
|
||||||
|
className={`w-full rounded-md border p-3 text-left transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-brand-gold bg-brand-gold/10"
|
||||||
|
: "border-border bg-surface-raised hover:bg-surface-sunken"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<button type="button" onClick={() => onSelectJob(job.job_id)} className="min-w-0 flex-1 text-left">
|
||||||
|
<div className="truncate font-semibold text-brand-navy">
|
||||||
|
{job.title || job.source_url}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate font-mono text-xs text-text-muted">
|
||||||
|
{job.source_url}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onDeleteJob(job.job_id)}
|
||||||
|
className="rounded-md border border-border px-2 py-1 text-text-muted transition-colors hover:bg-surface-sunken hover:text-fin-negative"
|
||||||
|
aria-label={`Delete transcript job ${job.title || job.job_id}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold ${STATUS_TONES[job.status]}`}>
|
||||||
|
<StatusIcon status={job.status} />
|
||||||
|
{STATUS_LABELS[job.status]}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-mono text-text-muted">{job.progress}%</span>
|
||||||
|
{job.language && <span className="text-xs font-mono text-brand-blue">{job.language.toUpperCase()}</span>}
|
||||||
|
{job.duration_sec != null && <span className="text-xs font-mono text-text-muted">{job.duration_sec}s</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-surface-sunken">
|
||||||
|
<div className="h-full rounded-full bg-brand-navy transition-[width] duration-500" style={{ width: `${Math.max(4, job.progress)}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-[11px] uppercase tracking-[0.1em] text-text-muted">
|
||||||
|
{new Date(job.created_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LoaderCircle, Search } from "lucide-react";
|
||||||
|
import type { VideoSearchHit } from "../../lib/video-transcript-types";
|
||||||
|
|
||||||
|
interface TranscriptSearchBarProps {
|
||||||
|
query: string;
|
||||||
|
searching: boolean;
|
||||||
|
results: VideoSearchHit[];
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
onSelectHit: (jobId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSnippet(snippet: string) {
|
||||||
|
return snippet.split(/(\[[^\]]+\])/g).map((part, index) => (
|
||||||
|
part.startsWith("[") && part.endsWith("]") ? (
|
||||||
|
<mark key={`${part}-${index}`} className="rounded bg-brand-gold/30 px-0.5 text-brand-navy">
|
||||||
|
{part.slice(1, -1)}
|
||||||
|
</mark>
|
||||||
|
) : (
|
||||||
|
<span key={`${part}-${index}`}>{part}</span>
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TranscriptSearchBar({
|
||||||
|
query,
|
||||||
|
searching,
|
||||||
|
results,
|
||||||
|
onQueryChange,
|
||||||
|
onSelectHit,
|
||||||
|
}: TranscriptSearchBarProps) {
|
||||||
|
const showResults = query.trim().length >= 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="atlas-card overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
<Search className="h-4 w-4 text-text-muted" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
placeholder="Search saved transcripts for earnings, margins, AI, guidance..."
|
||||||
|
className="w-full bg-transparent text-sm text-text-primary outline-none"
|
||||||
|
/>
|
||||||
|
{searching && <LoaderCircle className="h-4 w-4 animate-spin text-brand-blue" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showResults && (
|
||||||
|
<div className="max-h-72 overflow-auto bg-surface-raised">
|
||||||
|
{results.length > 0 ? (
|
||||||
|
results.map((hit) => (
|
||||||
|
<button
|
||||||
|
key={`${hit.job_id}-${hit.rank}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectHit(hit.job_id)}
|
||||||
|
className="block w-full border-b border-border px-4 py-3 text-left transition-colors hover:bg-surface-sunken"
|
||||||
|
>
|
||||||
|
<div className="truncate font-semibold text-brand-navy">
|
||||||
|
{hit.title || hit.job_id}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm leading-6 text-text-secondary">
|
||||||
|
{renderSnippet(hit.snippet)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : !searching ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-text-muted">
|
||||||
|
No saved transcript matched this query yet.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Link2, PlayCircle, UploadCloud, type LucideIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card } from "../ui/Card";
|
||||||
|
import type { VideoSourceType } from "../../lib/video-transcript-types";
|
||||||
|
|
||||||
|
interface TranscriptUploadFormProps {
|
||||||
|
submitting: boolean;
|
||||||
|
onSubmitSource: (url: string, sourceType: VideoSourceType, language?: string) => Promise<unknown>;
|
||||||
|
onUploadMedia: (file: File, language?: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputMode = "youtube" | "url" | "upload";
|
||||||
|
|
||||||
|
const TABS: Array<{ id: InputMode; label: string; icon: LucideIcon }> = [
|
||||||
|
{ id: "youtube", label: "YouTube", icon: PlayCircle },
|
||||||
|
{ id: "url", label: "Web URL", icon: Link2 },
|
||||||
|
{ id: "upload", label: "Upload", icon: UploadCloud },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TranscriptUploadForm({ submitting, onSubmitSource, onUploadMedia }: TranscriptUploadFormProps) {
|
||||||
|
const [mode, setMode] = useState<InputMode>("youtube");
|
||||||
|
const [sourceValue, setSourceValue] = useState("");
|
||||||
|
const [language, setLanguage] = useState("");
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setLocalError(null);
|
||||||
|
try {
|
||||||
|
if (mode === "upload") {
|
||||||
|
if (!selectedFile) {
|
||||||
|
setLocalError("Choose an mp4, audio file, or subtitle file first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onUploadMedia(selectedFile, language);
|
||||||
|
setSelectedFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceValue.trim()) {
|
||||||
|
setLocalError(mode === "youtube" ? "Paste a YouTube URL to continue." : "Paste a media URL or local file path to continue.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmitSource(sourceValue.trim(), mode === "youtube" ? "youtube" : "url", language);
|
||||||
|
setSourceValue("");
|
||||||
|
} catch {
|
||||||
|
// Parent hook already surfaces the actionable error banner.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="New Transcript Job"
|
||||||
|
subtitle="YouTube captions are tried first, then local Whisper transcription. Gemini analysis uses the key saved in Settings."
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{TABS.map(({ id, label, icon: Icon }) => {
|
||||||
|
const active = mode === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode(id)}
|
||||||
|
className={`flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-brand-navy bg-brand-navy text-white"
|
||||||
|
: "border-border bg-bg-card text-text-secondary hover:bg-surface-sunken hover:text-brand-navy"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "upload" ? (
|
||||||
|
<label className="block rounded-md border border-dashed border-border-strong bg-bg-primary p-4 text-sm text-text-secondary">
|
||||||
|
<span className="mb-2 block font-semibold text-brand-navy">Upload local media</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="video/*,audio/*,.mp4,.mov,.m4a,.mp3,.wav,.srt,.vtt"
|
||||||
|
onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)}
|
||||||
|
className="block w-full text-sm text-text-secondary file:mr-3 file:rounded-md file:border-0 file:bg-brand-navy file:px-3 file:py-2 file:text-sm file:font-semibold file:text-white hover:file:bg-brand-blue"
|
||||||
|
/>
|
||||||
|
<span className="mt-2 block text-xs text-text-muted">
|
||||||
|
Supported: mp4, mov, m4a, mp3, wav, srt, vtt
|
||||||
|
</span>
|
||||||
|
{selectedFile && (
|
||||||
|
<span className="mt-3 block rounded-md border border-brand-gold/40 bg-brand-gold/10 px-3 py-2 font-mono text-xs text-brand-navy">
|
||||||
|
{selectedFile.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm text-text-muted">
|
||||||
|
{mode === "youtube" ? "YouTube URL" : "Video URL or local path"}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={sourceValue}
|
||||||
|
onChange={(event) => setSourceValue(event.target.value)}
|
||||||
|
placeholder={mode === "youtube" ? "https://www.youtube.com/watch?v=..." : "https://example.com/video.mp4 or /Users/.../clip.mp4"}
|
||||||
|
className="w-full rounded-md border border-border bg-surface-raised px-3 py-2 text-sm text-text-primary outline-none transition-colors focus:border-brand-blue"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm text-text-muted">Language hint (optional)</span>
|
||||||
|
<input
|
||||||
|
value={language}
|
||||||
|
onChange={(event) => setLanguage(event.target.value)}
|
||||||
|
placeholder="en, ko, ja ..."
|
||||||
|
className="w-full rounded-md border border-border bg-surface-raised px-3 py-2 text-sm text-text-primary outline-none transition-colors focus:border-brand-blue"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{localError && (
|
||||||
|
<div className="rounded-md border border-fin-warning/40 bg-fin-warning/10 px-3 py-2 text-sm text-fin-warning">
|
||||||
|
{localError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full rounded-md bg-brand-navy px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-blue disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{submitting ? "Submitting..." : "Submit Transcript Job"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { VideoJob, VideoJobDetailResponse, VideoSearchHit, VideoSourceType, VideoTranslation } from "./video-transcript-types";
|
||||||
|
|
||||||
|
const PENDING_STATUSES = new Set(["queued", "fetching", "transcribing", "analyzing"]);
|
||||||
|
|
||||||
|
function getGeminiHeader(): HeadersInit {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
const apiKey = (localStorage.getItem("atlas_gemini_key") || "").trim();
|
||||||
|
return apiKey ? { "x-gemini-api-key": apiKey } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortJobs(jobs: VideoJob[]): VideoJob[] {
|
||||||
|
return [...jobs].sort((left, right) => right.created_at.localeCompare(left.created_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoTranscript() {
|
||||||
|
const [jobs, setJobs] = useState<VideoJob[]>([]);
|
||||||
|
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
||||||
|
const [detail, setDetail] = useState<VideoJobDetailResponse | null>(null);
|
||||||
|
const [searchResults, setSearchResults] = useState<VideoSearchHit[]>([]);
|
||||||
|
const [translationsByJob, setTranslationsByJob] = useState<Record<string, VideoTranslation>>({});
|
||||||
|
const [loadingJobs, setLoadingJobs] = useState(true);
|
||||||
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [translating, setTranslating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const request = useCallback(async (path: string, init?: RequestInit, json = true) => {
|
||||||
|
const response = await fetch(`/api/video${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(json ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...getGeminiHeader(),
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
throw new Error(payload?.detail || `Request failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshJobs = useCallback(async (showSpinner = true) => {
|
||||||
|
if (showSpinner) setLoadingJobs(true);
|
||||||
|
try {
|
||||||
|
const rows = (await request("/jobs")) as VideoJob[];
|
||||||
|
const sorted = sortJobs(rows);
|
||||||
|
setJobs(sorted);
|
||||||
|
setSelectedJobId((current) => {
|
||||||
|
if (current && sorted.some((job) => job.job_id === current)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return sorted[0]?.job_id ?? null;
|
||||||
|
});
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : "Failed to load transcript jobs.");
|
||||||
|
} finally {
|
||||||
|
if (showSpinner) setLoadingJobs(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
const loadJobDetail = useCallback(async (jobId: string, showSpinner = true) => {
|
||||||
|
if (showSpinner) setLoadingDetail(true);
|
||||||
|
try {
|
||||||
|
const payload = (await request(`/jobs/${jobId}`)) as VideoJobDetailResponse;
|
||||||
|
setDetail(payload);
|
||||||
|
setJobs((current) => {
|
||||||
|
const merged = current.some((job) => job.job_id === payload.job.job_id)
|
||||||
|
? current.map((job) => (job.job_id === payload.job.job_id ? payload.job : job))
|
||||||
|
: [payload.job, ...current];
|
||||||
|
return sortJobs(merged);
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : "Failed to load transcript detail.");
|
||||||
|
} finally {
|
||||||
|
if (showSpinner) setLoadingDetail(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshJobs();
|
||||||
|
}, [refreshJobs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedJobId) return;
|
||||||
|
void loadJobDetail(selectedJobId);
|
||||||
|
}, [selectedJobId, loadJobDetail]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedJobId || !detail || detail.job.job_id !== selectedJobId) return undefined;
|
||||||
|
if (!PENDING_STATUSES.has(detail.job.status)) return undefined;
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void loadJobDetail(selectedJobId, false);
|
||||||
|
void refreshJobs(false);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [detail, loadJobDetail, refreshJobs, selectedJobId]);
|
||||||
|
|
||||||
|
const submitSource = useCallback(async (url: string, sourceType: VideoSourceType, language?: string) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const job = (await request("/submit", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
url,
|
||||||
|
source_type: sourceType,
|
||||||
|
language: language?.trim() || null,
|
||||||
|
}),
|
||||||
|
})) as VideoJob;
|
||||||
|
setJobs((current) => sortJobs([job, ...current.filter((item) => item.job_id !== job.job_id)]));
|
||||||
|
setSelectedJobId(job.job_id);
|
||||||
|
setDetail({ job, transcript: null });
|
||||||
|
setError(null);
|
||||||
|
return job;
|
||||||
|
} catch (requestError) {
|
||||||
|
const message = requestError instanceof Error ? requestError.message : "Failed to submit transcript job.";
|
||||||
|
setError(message);
|
||||||
|
throw requestError;
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
const uploadMedia = useCallback(async (file: File, language?: string) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
if (language?.trim()) {
|
||||||
|
formData.append("language", language.trim());
|
||||||
|
}
|
||||||
|
const job = (await request("/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
}, false)) as VideoJob;
|
||||||
|
setJobs((current) => sortJobs([job, ...current.filter((item) => item.job_id !== job.job_id)]));
|
||||||
|
setSelectedJobId(job.job_id);
|
||||||
|
setDetail({ job, transcript: null });
|
||||||
|
setError(null);
|
||||||
|
return job;
|
||||||
|
} catch (requestError) {
|
||||||
|
const message = requestError instanceof Error ? requestError.message : "Failed to upload media.";
|
||||||
|
setError(message);
|
||||||
|
throw requestError;
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
const deleteTranscriptJob = useCallback(async (jobId: string) => {
|
||||||
|
try {
|
||||||
|
await request(`/jobs/${jobId}`, { method: "DELETE" });
|
||||||
|
setJobs((current) => {
|
||||||
|
const nextJobs = current.filter((job) => job.job_id !== jobId);
|
||||||
|
if (selectedJobId === jobId) {
|
||||||
|
setSelectedJobId(nextJobs[0]?.job_id ?? null);
|
||||||
|
if (nextJobs.length === 0) {
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nextJobs;
|
||||||
|
});
|
||||||
|
setTranslationsByJob((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[jobId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : "Failed to delete transcript job.");
|
||||||
|
}
|
||||||
|
}, [request, selectedJobId]);
|
||||||
|
|
||||||
|
const searchVideos = useCallback(async (query: string) => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const rows = (await request(`/search?q=${encodeURIComponent(query.trim())}`)) as VideoSearchHit[];
|
||||||
|
setSearchResults(rows);
|
||||||
|
setError(null);
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : "Failed to search transcripts.");
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
const clearSearch = useCallback(() => {
|
||||||
|
setSearchResults([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const translateJobToKorean = useCallback(async (jobId: string) => {
|
||||||
|
setTranslating(true);
|
||||||
|
try {
|
||||||
|
const payload = (await request(`/jobs/${jobId}/translate?target_language=ko`, {
|
||||||
|
method: "POST",
|
||||||
|
})) as VideoTranslation;
|
||||||
|
setTranslationsByJob((current) => ({
|
||||||
|
...current,
|
||||||
|
[jobId]: payload,
|
||||||
|
}));
|
||||||
|
setError(null);
|
||||||
|
return payload;
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : "Failed to translate transcript.");
|
||||||
|
throw requestError;
|
||||||
|
} finally {
|
||||||
|
setTranslating(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobs,
|
||||||
|
selectedJobId,
|
||||||
|
detail,
|
||||||
|
searchResults,
|
||||||
|
translation: detail ? translationsByJob[detail.job.job_id] ?? null : null,
|
||||||
|
loadingJobs,
|
||||||
|
loadingDetail,
|
||||||
|
submitting,
|
||||||
|
searching,
|
||||||
|
translating,
|
||||||
|
error,
|
||||||
|
selectJob: setSelectedJobId,
|
||||||
|
refreshJobs,
|
||||||
|
submitSource,
|
||||||
|
uploadMedia,
|
||||||
|
deleteTranscriptJob,
|
||||||
|
searchVideos,
|
||||||
|
clearSearch,
|
||||||
|
translateJobToKorean,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export type VideoSourceType = "youtube" | "url" | "local";
|
||||||
|
export type VideoJobStatus = "queued" | "fetching" | "transcribing" | "analyzing" | "completed" | "failed";
|
||||||
|
|
||||||
|
export interface VideoJob {
|
||||||
|
job_id: string;
|
||||||
|
status: VideoJobStatus;
|
||||||
|
source_url: string;
|
||||||
|
source_type: VideoSourceType;
|
||||||
|
progress: number;
|
||||||
|
error: string | null;
|
||||||
|
title: string | null;
|
||||||
|
duration_sec: number | null;
|
||||||
|
language: string | null;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoTranscript {
|
||||||
|
job_id: string;
|
||||||
|
text: string;
|
||||||
|
summary: string | null;
|
||||||
|
keywords: string[];
|
||||||
|
topics: string[];
|
||||||
|
sentiment: "positive" | "neutral" | "negative" | null;
|
||||||
|
intent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoSearchHit {
|
||||||
|
job_id: string;
|
||||||
|
title: string | null;
|
||||||
|
snippet: string;
|
||||||
|
rank: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoJobDetailResponse {
|
||||||
|
job: VideoJob;
|
||||||
|
transcript: VideoTranscript | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoTranslation {
|
||||||
|
job_id: string;
|
||||||
|
target_language: string;
|
||||||
|
summary: string | null;
|
||||||
|
keywords: string[];
|
||||||
|
topics: string[];
|
||||||
|
intent: string | null;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { startTransition, useDeferredValue, useEffect, useState } from "react";
|
||||||
|
import { TranscriptDetailPanel } from "../components/transcripts/TranscriptDetailPanel";
|
||||||
|
import { TranscriptJobList } from "../components/transcripts/TranscriptJobList";
|
||||||
|
import { TranscriptSearchBar } from "../components/transcripts/TranscriptSearchBar";
|
||||||
|
import { TranscriptUploadForm } from "../components/transcripts/TranscriptUploadForm";
|
||||||
|
import { ErrorBanner } from "../components/ui/ErrorBanner";
|
||||||
|
import { SectionHeading } from "../components/ui/SectionHeading";
|
||||||
|
import { useVideoTranscript } from "../lib/use-video-transcript";
|
||||||
|
|
||||||
|
export default function TranscriptsPage() {
|
||||||
|
const {
|
||||||
|
jobs,
|
||||||
|
selectedJobId,
|
||||||
|
detail,
|
||||||
|
searchResults,
|
||||||
|
translation,
|
||||||
|
loadingJobs,
|
||||||
|
loadingDetail,
|
||||||
|
submitting,
|
||||||
|
searching,
|
||||||
|
translating,
|
||||||
|
error,
|
||||||
|
selectJob,
|
||||||
|
submitSource,
|
||||||
|
uploadMedia,
|
||||||
|
deleteTranscriptJob,
|
||||||
|
searchVideos,
|
||||||
|
clearSearch,
|
||||||
|
translateJobToKorean,
|
||||||
|
} = useVideoTranscript();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const deferredQuery = useDeferredValue(searchQuery);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (deferredQuery.trim().length < 2) {
|
||||||
|
clearSearch();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void searchVideos(deferredQuery.trim());
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [clearSearch, deferredQuery, searchVideos]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="atlas-page">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<SectionHeading level={1}>Transcripts</SectionHeading>
|
||||||
|
<p className="atlas-page-subtitle">
|
||||||
|
Extract captions or speech from YouTube clips, local media, and direct video URLs, then store the results for analysis and full-text search.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ErrorBanner variant="error" message={error} />
|
||||||
|
|
||||||
|
<TranscriptSearchBar
|
||||||
|
query={searchQuery}
|
||||||
|
searching={searching}
|
||||||
|
results={searchResults}
|
||||||
|
onQueryChange={setSearchQuery}
|
||||||
|
onSelectHit={(jobId) => {
|
||||||
|
startTransition(() => {
|
||||||
|
selectJob(jobId);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<TranscriptUploadForm
|
||||||
|
submitting={submitting}
|
||||||
|
onSubmitSource={submitSource}
|
||||||
|
onUploadMedia={uploadMedia}
|
||||||
|
/>
|
||||||
|
<TranscriptJobList
|
||||||
|
jobs={jobs}
|
||||||
|
loading={loadingJobs}
|
||||||
|
selectedJobId={selectedJobId}
|
||||||
|
onSelectJob={selectJob}
|
||||||
|
onDeleteJob={deleteTranscriptJob}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TranscriptDetailPanel
|
||||||
|
detail={detail}
|
||||||
|
loading={loadingDetail}
|
||||||
|
translation={translation}
|
||||||
|
translating={translating}
|
||||||
|
onTranslate={translateJobToKorean}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ requests>=2.31.0
|
|||||||
pandas>=2.0.0
|
pandas>=2.0.0
|
||||||
lxml>=4.9.0
|
lxml>=4.9.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
yfinance>=0.2.40
|
yfinance>=0.2.40
|
||||||
yahooquery>=2.2.0
|
yahooquery>=2.2.0
|
||||||
sec-edgar-downloader>=5.0.0
|
sec-edgar-downloader>=5.0.0
|
||||||
@@ -24,3 +25,8 @@ dart-fss>=0.4.0
|
|||||||
numpy>=1.20.0
|
numpy>=1.20.0
|
||||||
scipy>=1.10.0
|
scipy>=1.10.0
|
||||||
dbnomics>=1.2.0
|
dbnomics>=1.2.0
|
||||||
|
faster-whisper>=1.0.3
|
||||||
|
yt-dlp>=2024.10.7
|
||||||
|
srt>=3.5.3
|
||||||
|
ffmpeg-python>=0.2.0
|
||||||
|
imageio-ffmpeg>=0.6.0
|
||||||
|
|||||||
@@ -133,6 +133,57 @@ async def init_db() -> None:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS video_jobs (
|
||||||
|
job_id TEXT PRIMARY KEY,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
source_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
title TEXT,
|
||||||
|
duration_sec INTEGER,
|
||||||
|
language TEXT,
|
||||||
|
transcript_text TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
keywords_json TEXT,
|
||||||
|
topics_json TEXT,
|
||||||
|
sentiment TEXT,
|
||||||
|
intent TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_jobs_created_at ON video_jobs(created_at DESC);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS video_jobs_fts USING fts5(
|
||||||
|
job_id UNINDEXED,
|
||||||
|
title,
|
||||||
|
transcript_text,
|
||||||
|
content='video_jobs',
|
||||||
|
content_rowid='rowid'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS video_jobs_ai AFTER INSERT ON video_jobs BEGIN
|
||||||
|
INSERT INTO video_jobs_fts(rowid, job_id, title, transcript_text)
|
||||||
|
VALUES (new.rowid, new.job_id, new.title, new.transcript_text);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS video_jobs_ad AFTER DELETE ON video_jobs BEGIN
|
||||||
|
INSERT INTO video_jobs_fts(video_jobs_fts, rowid, job_id, title, transcript_text)
|
||||||
|
VALUES ('delete', old.rowid, old.job_id, old.title, old.transcript_text);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS video_jobs_au AFTER UPDATE ON video_jobs BEGIN
|
||||||
|
INSERT INTO video_jobs_fts(video_jobs_fts, rowid, job_id, title, transcript_text)
|
||||||
|
VALUES ('delete', old.rowid, old.job_id, old.title, old.transcript_text);
|
||||||
|
INSERT INTO video_jobs_fts(rowid, job_id, title, transcript_text)
|
||||||
|
VALUES (new.rowid, new.job_id, new.title, new.transcript_text);
|
||||||
|
END;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,35 @@ async def init_pg_tables() -> None:
|
|||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_credential_access_log_at ON credential_access_log(at);
|
CREATE INDEX IF NOT EXISTS idx_credential_access_log_at ON credential_access_log(at);
|
||||||
""")
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS video_jobs (
|
||||||
|
job_id TEXT PRIMARY KEY,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
source_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
title TEXT,
|
||||||
|
duration_sec INTEGER,
|
||||||
|
language TEXT,
|
||||||
|
transcript_text TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
keywords_json JSONB DEFAULT '[]'::jsonb,
|
||||||
|
topics_json JSONB DEFAULT '[]'::jsonb,
|
||||||
|
sentiment TEXT,
|
||||||
|
intent TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_jobs_created_at ON video_jobs(created_at DESC);
|
||||||
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_jobs_search
|
||||||
|
ON video_jobs
|
||||||
|
USING GIN (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(transcript_text, '')));
|
||||||
|
""")
|
||||||
logger.info("PostgreSQL tables initialized.")
|
logger.info("PostgreSQL tables initialized.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""PostgreSQL repository for persisted video transcript jobs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from server.db.pg_database import get_pg_pool
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(item) for item in value if str(item).strip()]
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return [value]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_datetime(value: Any) -> Any:
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_row(row: Any) -> dict[str, Any]:
|
||||||
|
payload = dict(row)
|
||||||
|
payload["keywords"] = _coerce_list(payload.pop("keywords_json", []))
|
||||||
|
payload["topics"] = _coerce_list(payload.pop("topics_json", []))
|
||||||
|
if payload.get("created_at") is not None:
|
||||||
|
payload["created_at"] = payload["created_at"].isoformat()
|
||||||
|
if payload.get("completed_at") is not None:
|
||||||
|
payload["completed_at"] = payload["completed_at"].isoformat()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_updates(fields: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
alias_map = {"keywords": "keywords_json", "topics": "topics_json"}
|
||||||
|
allowed = {
|
||||||
|
"source_url",
|
||||||
|
"source_type",
|
||||||
|
"status",
|
||||||
|
"progress",
|
||||||
|
"error",
|
||||||
|
"title",
|
||||||
|
"duration_sec",
|
||||||
|
"language",
|
||||||
|
"transcript_text",
|
||||||
|
"summary",
|
||||||
|
"keywords_json",
|
||||||
|
"topics_json",
|
||||||
|
"sentiment",
|
||||||
|
"intent",
|
||||||
|
"completed_at",
|
||||||
|
}
|
||||||
|
normalised: dict[str, Any] = {}
|
||||||
|
for key, value in fields.items():
|
||||||
|
mapped_key = alias_map.get(key, key)
|
||||||
|
if mapped_key not in allowed:
|
||||||
|
continue
|
||||||
|
if mapped_key in {"keywords_json", "topics_json"}:
|
||||||
|
normalised[mapped_key] = _coerce_list(value)
|
||||||
|
elif mapped_key in {"completed_at"}:
|
||||||
|
normalised[mapped_key] = _coerce_datetime(value)
|
||||||
|
else:
|
||||||
|
normalised[mapped_key] = value
|
||||||
|
return normalised
|
||||||
|
|
||||||
|
|
||||||
|
async def pg_add_video_job(job_id: str, url: str, source_type: str) -> dict[str, Any] | None:
|
||||||
|
pool = await get_pg_pool()
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO video_jobs (job_id, source_url, source_type, status, progress)
|
||||||
|
VALUES ($1, $2, $3, 'queued', 0)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
job_id,
|
||||||
|
url,
|
||||||
|
source_type,
|
||||||
|
)
|
||||||
|
return _normalise_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def pg_update_video_job(job_id: str, **fields: Any) -> dict[str, Any] | None:
|
||||||
|
pool = await get_pg_pool()
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
updates = _normalise_updates(fields)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if updates:
|
||||||
|
set_clause = ", ".join(f"{column} = ${index + 2}" for index, column in enumerate(updates))
|
||||||
|
values = [job_id] + list(updates.values())
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"UPDATE video_jobs SET {set_clause} WHERE job_id = $1 RETURNING *", # noqa: S608
|
||||||
|
*values,
|
||||||
|
)
|
||||||
|
return _normalise_row(row) if row else None
|
||||||
|
row = await conn.fetchrow("SELECT * FROM video_jobs WHERE job_id = $1", job_id)
|
||||||
|
return _normalise_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def pg_get_video_job(job_id: str) -> dict[str, Any] | None:
|
||||||
|
pool = await get_pg_pool()
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("SELECT * FROM video_jobs WHERE job_id = $1", job_id)
|
||||||
|
return _normalise_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def pg_list_video_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
pool = await get_pg_pool()
|
||||||
|
if not pool:
|
||||||
|
return []
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
job_id, source_url, source_type, status, progress, error, title,
|
||||||
|
duration_sec, language, created_at, completed_at
|
||||||
|
FROM video_jobs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1
|
||||||
|
""",
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
return [_normalise_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def pg_search_videos(query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
pool = await get_pg_pool()
|
||||||
|
if not pool:
|
||||||
|
return []
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
job_id,
|
||||||
|
title,
|
||||||
|
ts_headline(
|
||||||
|
'simple',
|
||||||
|
coalesce(transcript_text, ''),
|
||||||
|
plainto_tsquery('simple', $1),
|
||||||
|
'StartSel=[,StopSel=],MaxFragments=1,MaxWords=18,MinWords=6'
|
||||||
|
) AS snippet,
|
||||||
|
ts_rank_cd(
|
||||||
|
to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(transcript_text, '')),
|
||||||
|
plainto_tsquery('simple', $1)
|
||||||
|
) AS rank
|
||||||
|
FROM video_jobs
|
||||||
|
WHERE to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(transcript_text, ''))
|
||||||
|
@@ plainto_tsquery('simple', $1)
|
||||||
|
ORDER BY rank DESC, created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
""",
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"job_id": row["job_id"],
|
||||||
|
"title": row["title"],
|
||||||
|
"snippet": row["snippet"] or "",
|
||||||
|
"rank": float(row["rank"] or 0.0),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def pg_delete_video_job(job_id: str) -> bool:
|
||||||
|
pool = await get_pg_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result = await conn.execute("DELETE FROM video_jobs WHERE job_id = $1", job_id)
|
||||||
|
return result == "DELETE 1"
|
||||||
@@ -70,6 +70,48 @@ class UnifiedRepo:
|
|||||||
from server.db.cache import cache_manager
|
from server.db.cache import cache_manager
|
||||||
await cache_manager.set(key, value, ttl)
|
await cache_manager.set(key, value, ttl)
|
||||||
|
|
||||||
|
async def add_video_job(self, job_id: str, url: str, source_type: str) -> Optional[dict]:
|
||||||
|
if _use_postgres():
|
||||||
|
from server.db.pg_video_repo import pg_add_video_job
|
||||||
|
return await pg_add_video_job(job_id, url, source_type)
|
||||||
|
from server.db.video_repo import add_video_job
|
||||||
|
return await add_video_job(job_id, url, source_type)
|
||||||
|
|
||||||
|
async def update_video_job(self, job_id: str, **fields: Any) -> Optional[dict]:
|
||||||
|
if _use_postgres():
|
||||||
|
from server.db.pg_video_repo import pg_update_video_job
|
||||||
|
return await pg_update_video_job(job_id, **fields)
|
||||||
|
from server.db.video_repo import update_video_job
|
||||||
|
return await update_video_job(job_id, **fields)
|
||||||
|
|
||||||
|
async def get_video_job(self, job_id: str) -> Optional[dict]:
|
||||||
|
if _use_postgres():
|
||||||
|
from server.db.pg_video_repo import pg_get_video_job
|
||||||
|
return await pg_get_video_job(job_id)
|
||||||
|
from server.db.video_repo import get_video_job
|
||||||
|
return await get_video_job(job_id)
|
||||||
|
|
||||||
|
async def list_video_jobs(self, limit: int = 50) -> list[dict]:
|
||||||
|
if _use_postgres():
|
||||||
|
from server.db.pg_video_repo import pg_list_video_jobs
|
||||||
|
return await pg_list_video_jobs(limit=limit)
|
||||||
|
from server.db.video_repo import list_video_jobs
|
||||||
|
return await list_video_jobs(limit=limit)
|
||||||
|
|
||||||
|
async def search_videos(self, query: str, limit: int = 20) -> list[dict]:
|
||||||
|
if _use_postgres():
|
||||||
|
from server.db.pg_video_repo import pg_search_videos
|
||||||
|
return await pg_search_videos(query, limit=limit)
|
||||||
|
from server.db.video_repo import search_videos
|
||||||
|
return await search_videos(query, limit=limit)
|
||||||
|
|
||||||
|
async def delete_video_job(self, job_id: str) -> bool:
|
||||||
|
if _use_postgres():
|
||||||
|
from server.db.pg_video_repo import pg_delete_video_job
|
||||||
|
return await pg_delete_video_job(job_id)
|
||||||
|
from server.db.video_repo import delete_video_job
|
||||||
|
return await delete_video_job(job_id)
|
||||||
|
|
||||||
async def init_db(self) -> None:
|
async def init_db(self) -> None:
|
||||||
"""Initialize the appropriate database."""
|
"""Initialize the appropriate database."""
|
||||||
if _use_postgres():
|
if _use_postgres():
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""SQLite repository for persisted video transcript jobs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from server.db.database import get_db
|
||||||
|
|
||||||
|
_SEARCH_TOKEN_RE = re.compile(r"[A-Za-z0-9_]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_json_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(item) for item in value if str(item).strip()]
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return [value]
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [str(item) for item in parsed if str(item).strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row: aiosqlite.Row | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
data = dict(row)
|
||||||
|
data["keywords"] = _coerce_json_list(data.pop("keywords_json", []))
|
||||||
|
data["topics"] = _coerce_json_list(data.pop("topics_json", []))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_updates(fields: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
alias_map = {
|
||||||
|
"keywords": "keywords_json",
|
||||||
|
"topics": "topics_json",
|
||||||
|
}
|
||||||
|
allowed = {
|
||||||
|
"source_url",
|
||||||
|
"source_type",
|
||||||
|
"status",
|
||||||
|
"progress",
|
||||||
|
"error",
|
||||||
|
"title",
|
||||||
|
"duration_sec",
|
||||||
|
"language",
|
||||||
|
"transcript_text",
|
||||||
|
"summary",
|
||||||
|
"keywords_json",
|
||||||
|
"topics_json",
|
||||||
|
"sentiment",
|
||||||
|
"intent",
|
||||||
|
"completed_at",
|
||||||
|
}
|
||||||
|
normalised: dict[str, Any] = {}
|
||||||
|
for key, value in fields.items():
|
||||||
|
mapped_key = alias_map.get(key, key)
|
||||||
|
if mapped_key not in allowed:
|
||||||
|
continue
|
||||||
|
if mapped_key in {"keywords_json", "topics_json"}:
|
||||||
|
normalised[mapped_key] = json.dumps(_coerce_json_list(value), ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
normalised[mapped_key] = value
|
||||||
|
return normalised
|
||||||
|
|
||||||
|
|
||||||
|
def _build_fts_query(query: str) -> str:
|
||||||
|
tokens = [token.lower() for token in _SEARCH_TOKEN_RE.findall(query)]
|
||||||
|
if not tokens:
|
||||||
|
return ""
|
||||||
|
return " AND ".join(f"{token}*" for token in tokens)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_video_job(job_id: str, url: str, source_type: str) -> dict[str, Any] | None:
|
||||||
|
db = await get_db()
|
||||||
|
created_at = _now_iso()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO video_jobs (
|
||||||
|
job_id, source_url, source_type, status, progress, created_at
|
||||||
|
) VALUES (?, ?, ?, 'queued', 0, ?)
|
||||||
|
""",
|
||||||
|
(job_id, url, source_type, created_at),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await get_video_job(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_video_job(job_id: str, **fields: Any) -> dict[str, Any] | None:
|
||||||
|
updates = _normalise_updates(fields)
|
||||||
|
db = await get_db()
|
||||||
|
if updates:
|
||||||
|
set_clause = ", ".join(f"{column} = ?" for column in updates)
|
||||||
|
values = list(updates.values()) + [job_id]
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE video_jobs SET {set_clause} WHERE job_id = ?", # noqa: S608
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await get_video_job(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_video_job(job_id: str) -> dict[str, Any] | None:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute("SELECT * FROM video_jobs WHERE job_id = ?", (job_id,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_video_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
job_id, source_url, source_type, status, progress, error, title,
|
||||||
|
duration_sec, language, created_at, completed_at
|
||||||
|
FROM video_jobs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [row for row in (_row_to_dict(item) for item in rows) if row is not None]
|
||||||
|
|
||||||
|
|
||||||
|
async def search_videos(query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
fts_query = _build_fts_query(query)
|
||||||
|
if not fts_query:
|
||||||
|
return []
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
video_jobs.job_id,
|
||||||
|
video_jobs.title,
|
||||||
|
COALESCE(
|
||||||
|
snippet(video_jobs_fts, 2, '[', ']', '...', 16),
|
||||||
|
substr(video_jobs.transcript_text, 1, 180)
|
||||||
|
) AS snippet,
|
||||||
|
(1.0 / (1.0 + bm25(video_jobs_fts))) AS rank
|
||||||
|
FROM video_jobs_fts
|
||||||
|
JOIN video_jobs ON video_jobs.rowid = video_jobs_fts.rowid
|
||||||
|
WHERE video_jobs_fts MATCH ?
|
||||||
|
ORDER BY bm25(video_jobs_fts), video_jobs.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(fts_query, limit),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"job_id": row["job_id"],
|
||||||
|
"title": row["title"],
|
||||||
|
"snippet": row["snippet"] or "",
|
||||||
|
"rank": float(row["rank"] or 0.0),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_video_job(job_id: str) -> bool:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute("DELETE FROM video_jobs WHERE job_id = ?", (job_id,))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
@@ -81,7 +81,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Mount routers ---
|
# --- Mount routers ---
|
||||||
from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider, screener, markets, fmp, macro, dart, edinet, research, chat, copilot, credentials, calendar, tax # noqa: E402
|
from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider, screener, markets, fmp, macro, dart, edinet, research, chat, copilot, credentials, calendar, tax, video_transcript # noqa: E402
|
||||||
|
|
||||||
app.include_router(edgar.router, prefix="/api/edgar", tags=["SEC EDGAR"])
|
app.include_router(edgar.router, prefix="/api/edgar", tags=["SEC EDGAR"])
|
||||||
app.include_router(analysis.router, prefix="/api/analysis", tags=["AI Analysis"])
|
app.include_router(analysis.router, prefix="/api/analysis", tags=["AI Analysis"])
|
||||||
@@ -107,6 +107,7 @@ app.include_router(chat.router, prefix="/api/chat", tags=["Chat"])
|
|||||||
app.include_router(copilot.router, prefix="/api/copilot", tags=["Copilot"])
|
app.include_router(copilot.router, prefix="/api/copilot", tags=["Copilot"])
|
||||||
app.include_router(credentials.router, prefix="/api/credentials", tags=["Credentials"])
|
app.include_router(credentials.router, prefix="/api/credentials", tags=["Credentials"])
|
||||||
app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"])
|
app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"])
|
||||||
|
app.include_router(video_transcript.router, prefix="/api/video", tags=["Video Transcript"])
|
||||||
app.include_router(tax.router, prefix="/api/tax", tags=["Tax"])
|
app.include_router(tax.router, prefix="/api/tax", tags=["Tax"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -367,3 +367,56 @@ class HealthCheckResponse(BaseModel):
|
|||||||
"""API health check."""
|
"""API health check."""
|
||||||
status: str = "ok"
|
status: str = "ok"
|
||||||
version: str = "1.0.0"
|
version: str = "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class VideoSubmitRequest(BaseModel):
|
||||||
|
"""Request to start transcript extraction for a video or audio source."""
|
||||||
|
url: str = Field(..., description="YouTube URL, web URL, or local file path")
|
||||||
|
source_type: Literal["youtube", "url", "local"] = "url"
|
||||||
|
language: Optional[str] = Field(default=None, description="Optional source language hint for Whisper")
|
||||||
|
|
||||||
|
|
||||||
|
class VideoJob(BaseModel):
|
||||||
|
"""Video transcript job metadata."""
|
||||||
|
job_id: str
|
||||||
|
status: Literal["queued", "fetching", "transcribing", "analyzing", "completed", "failed"]
|
||||||
|
source_url: str
|
||||||
|
source_type: Literal["youtube", "url", "local"]
|
||||||
|
progress: int = 0
|
||||||
|
error: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
duration_sec: Optional[int] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
created_at: str
|
||||||
|
completed_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VideoTranscript(BaseModel):
|
||||||
|
"""Stored transcript text plus analysis output."""
|
||||||
|
job_id: str
|
||||||
|
text: str
|
||||||
|
summary: Optional[str] = None
|
||||||
|
keywords: List[str] = Field(default_factory=list)
|
||||||
|
topics: List[str] = Field(default_factory=list)
|
||||||
|
sentiment: Optional[Literal["positive", "neutral", "negative"]] = None
|
||||||
|
intent: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VideoSearchHit(BaseModel):
|
||||||
|
"""One transcript full-text search match."""
|
||||||
|
job_id: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
snippet: str
|
||||||
|
rank: float
|
||||||
|
|
||||||
|
|
||||||
|
class VideoTranslation(BaseModel):
|
||||||
|
"""On-demand translated transcript payload."""
|
||||||
|
job_id: str
|
||||||
|
target_language: str = "ko"
|
||||||
|
summary: Optional[str] = None
|
||||||
|
keywords: List[str] = Field(default_factory=list)
|
||||||
|
topics: List[str] = Field(default_factory=list)
|
||||||
|
intent: Optional[str] = None
|
||||||
|
text: str = ""
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Video transcript endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, Header, HTTPException, Query, UploadFile
|
||||||
|
|
||||||
|
from server.models.schemas import VideoJob, VideoSearchHit, VideoSubmitRequest, VideoTranscript, VideoTranslation
|
||||||
|
from server.services.video_transcript_service import (
|
||||||
|
delete_job,
|
||||||
|
get_job_detail,
|
||||||
|
list_jobs,
|
||||||
|
search_jobs,
|
||||||
|
submit_job,
|
||||||
|
submit_upload,
|
||||||
|
translate_job_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_api_key(header_value: str | None) -> str | None:
|
||||||
|
return (header_value or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/submit", response_model=VideoJob, summary="Submit a video or audio transcript job")
|
||||||
|
async def submit_video_job(
|
||||||
|
body: VideoSubmitRequest,
|
||||||
|
x_gemini_api_key: str | None = Header(default=None),
|
||||||
|
) -> VideoJob:
|
||||||
|
job_id = await submit_job(
|
||||||
|
body.url,
|
||||||
|
body.source_type,
|
||||||
|
language=body.language,
|
||||||
|
api_key=_resolve_api_key(x_gemini_api_key),
|
||||||
|
)
|
||||||
|
detail = await get_job_detail(job_id)
|
||||||
|
if detail is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create transcript job.")
|
||||||
|
return VideoJob(**detail["job"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=VideoJob, summary="Upload local media and start transcript extraction")
|
||||||
|
async def upload_video_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
language: str | None = Form(default=None),
|
||||||
|
x_gemini_api_key: str | None = Header(default=None),
|
||||||
|
) -> VideoJob:
|
||||||
|
job_id = await submit_upload(
|
||||||
|
file,
|
||||||
|
language=language,
|
||||||
|
api_key=_resolve_api_key(x_gemini_api_key),
|
||||||
|
)
|
||||||
|
detail = await get_job_detail(job_id)
|
||||||
|
if detail is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create transcript job.")
|
||||||
|
return VideoJob(**detail["job"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs", response_model=list[VideoJob], summary="List recent transcript jobs")
|
||||||
|
async def video_jobs(limit: int = Query(50, ge=1, le=200)) -> list[VideoJob]:
|
||||||
|
rows = await list_jobs(limit=limit)
|
||||||
|
return [VideoJob(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}", summary="Get transcript job details")
|
||||||
|
async def video_job_detail(job_id: str) -> dict[str, VideoJob | VideoTranscript | None]:
|
||||||
|
payload = await get_job_detail(job_id)
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Transcript job not found.")
|
||||||
|
transcript = payload["transcript"]
|
||||||
|
return {
|
||||||
|
"job": VideoJob(**payload["job"]),
|
||||||
|
"transcript": None if transcript is None else VideoTranscript(**transcript),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/translate", response_model=VideoTranslation, summary="Translate a stored transcript on demand")
|
||||||
|
async def translate_video_job(
|
||||||
|
job_id: str,
|
||||||
|
x_gemini_api_key: str | None = Header(default=None),
|
||||||
|
target_language: str = Query("ko", pattern="^[a-z]{2}$"),
|
||||||
|
) -> VideoTranslation:
|
||||||
|
try:
|
||||||
|
payload = await translate_job_content(
|
||||||
|
job_id,
|
||||||
|
target_language=target_language,
|
||||||
|
api_key=_resolve_api_key(x_gemini_api_key),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Transcript job not found.")
|
||||||
|
return VideoTranslation(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=list[VideoSearchHit], summary="Full-text search saved transcripts")
|
||||||
|
async def search_transcripts(
|
||||||
|
q: str = Query(..., min_length=1, description="Query text"),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
) -> list[VideoSearchHit]:
|
||||||
|
rows = await search_jobs(q, limit=limit)
|
||||||
|
return [VideoSearchHit(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/jobs/{job_id}", summary="Delete a transcript job")
|
||||||
|
async def delete_video_job(job_id: str) -> dict[str, object]:
|
||||||
|
deleted = await delete_job(job_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Transcript job not found.")
|
||||||
|
return {"job_id": job_id, "deleted": True}
|
||||||
@@ -40,7 +40,12 @@ def get_gemini_model(api_key: str) -> Any:
|
|||||||
return genai.GenerativeModel(GEMINI_MODEL)
|
return genai.GenerativeModel(GEMINI_MODEL)
|
||||||
|
|
||||||
|
|
||||||
async def generate_text(prompt: str, temperature: float = 0.3, max_tokens: int = 1200) -> str:
|
async def generate_text(
|
||||||
|
prompt: str,
|
||||||
|
temperature: float = 0.3,
|
||||||
|
max_tokens: int = 1200,
|
||||||
|
api_key: str | None = None,
|
||||||
|
) -> str:
|
||||||
"""Async convenience wrapper used by lightweight best-effort AI features.
|
"""Async convenience wrapper used by lightweight best-effort AI features.
|
||||||
|
|
||||||
It reads a server-side Gemini key from the environment. Browser-local keys
|
It reads a server-side Gemini key from the environment. Browser-local keys
|
||||||
@@ -48,12 +53,12 @@ async def generate_text(prompt: str, temperature: float = 0.3, max_tokens: int =
|
|||||||
secrets implicitly from localStorage.
|
secrets implicitly from localStorage.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
api_key = (os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") or "").strip()
|
resolved_api_key = (api_key or os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") or "").strip()
|
||||||
if not api_key:
|
if not resolved_api_key:
|
||||||
raise RuntimeError("GOOGLE_API_KEY or GEMINI_API_KEY is not configured")
|
raise RuntimeError("GOOGLE_API_KEY or GEMINI_API_KEY is not configured")
|
||||||
|
|
||||||
def _run() -> str:
|
def _run() -> str:
|
||||||
model = get_gemini_model(api_key)
|
model = get_gemini_model(resolved_api_key)
|
||||||
response = _generate_with_retry(
|
response = _generate_with_retry(
|
||||||
model,
|
model,
|
||||||
prompt,
|
prompt,
|
||||||
|
|||||||
@@ -0,0 +1,929 @@
|
|||||||
|
"""Video transcript ingestion, transcription, and analysis service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any, Literal
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from server.db.unified_repo import repo
|
||||||
|
from server.services.gemini_service import generate_text
|
||||||
|
from server.services.text_chunker import _split_into_chunks, smart_chunk
|
||||||
|
|
||||||
|
try: # pragma: no cover - optional dependency is exercised in integration use.
|
||||||
|
import ffmpeg
|
||||||
|
except ImportError: # pragma: no cover - tests run without multimedia stack installed.
|
||||||
|
ffmpeg = None
|
||||||
|
|
||||||
|
try: # pragma: no cover - optional dependency is exercised in integration use.
|
||||||
|
import imageio_ffmpeg
|
||||||
|
except ImportError: # pragma: no cover - tests run without fallback binary installed.
|
||||||
|
imageio_ffmpeg = None
|
||||||
|
|
||||||
|
try: # pragma: no cover - optional dependency is exercised in integration use.
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
except ImportError: # pragma: no cover - tests run without Whisper installed.
|
||||||
|
WhisperModel = None
|
||||||
|
|
||||||
|
try: # pragma: no cover - optional dependency is exercised in integration use.
|
||||||
|
import srt
|
||||||
|
except ImportError: # pragma: no cover - tests run without subtitle stack installed.
|
||||||
|
srt = None
|
||||||
|
|
||||||
|
try: # pragma: no cover - optional dependency is exercised in integration use.
|
||||||
|
from yt_dlp import YoutubeDL
|
||||||
|
except ImportError: # pragma: no cover - tests run without downloader installed.
|
||||||
|
YoutubeDL = None
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_YOUTUBE_CACHE_TTL = 30 * 24 * 60 * 60
|
||||||
|
_REMOTE_MAX_BYTES = 500 * 1024 * 1024
|
||||||
|
_PENDING_STATUSES = {"queued", "fetching", "transcribing", "analyzing"}
|
||||||
|
_WHISPER_MODEL_LOCK = Lock()
|
||||||
|
_WHISPER_MODEL: Any | None = None
|
||||||
|
_TASKS: dict[str, asyncio.Task[None]] = {}
|
||||||
|
|
||||||
|
_TEXT_STOPWORDS = {
|
||||||
|
"about", "after", "again", "also", "been", "being", "because", "between", "could", "every",
|
||||||
|
"from", "have", "into", "just", "like", "more", "most", "only", "other", "over", "should",
|
||||||
|
"some", "such", "than", "that", "their", "there", "these", "they", "this", "those", "through",
|
||||||
|
"very", "were", "what", "when", "where", "which", "while", "with", "would", "your", "ourselves",
|
||||||
|
"ours", "you", "them", "then", "well", "will", "call", "speaker", "question", "answer", "video",
|
||||||
|
}
|
||||||
|
_POSITIVE_WORDS = {"growth", "strong", "improve", "upside", "record", "bullish", "accelerate", "opportunity"}
|
||||||
|
_NEGATIVE_WORDS = {"risk", "weak", "decline", "pressure", "downside", "loss", "uncertain", "headwind"}
|
||||||
|
_YOUTUBE_HOSTS = {
|
||||||
|
"youtube.com",
|
||||||
|
"www.youtube.com",
|
||||||
|
"m.youtube.com",
|
||||||
|
"youtu.be",
|
||||||
|
"www.youtu.be",
|
||||||
|
}
|
||||||
|
_VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"}
|
||||||
|
_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".webm"}
|
||||||
|
_CAPTION_EXTENSIONS = {".srt", ".vtt"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SourceMaterial:
|
||||||
|
"""Resolved source asset for one transcript job."""
|
||||||
|
|
||||||
|
title: str | None = None
|
||||||
|
duration_sec: int | None = None
|
||||||
|
language: str | None = None
|
||||||
|
transcript_text: str | None = None
|
||||||
|
media_path: Path | None = None
|
||||||
|
cleanup_paths: list[Path] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_gemini_api_key(api_key: str | None = None) -> str | None:
|
||||||
|
resolved = (api_key or os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") or "").strip()
|
||||||
|
return resolved or None
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(source_url: str) -> str:
|
||||||
|
return f"video-transcript:{source_url.strip()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _display_name_from_source(source_url: str) -> str:
|
||||||
|
parsed = urlparse(source_url)
|
||||||
|
if parsed.scheme:
|
||||||
|
if parsed.path:
|
||||||
|
tail = Path(parsed.path).name
|
||||||
|
return tail or parsed.netloc or source_url
|
||||||
|
return parsed.netloc or source_url
|
||||||
|
return Path(source_url).expanduser().name or source_url
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_source_type(url_or_path: str, source_type: Literal["youtube", "url", "local"]) -> Literal["youtube", "url", "local"]:
|
||||||
|
candidate = (url_or_path or "").strip()
|
||||||
|
if source_type == "youtube":
|
||||||
|
return "youtube"
|
||||||
|
if source_type == "local":
|
||||||
|
return "local"
|
||||||
|
parsed = urlparse(candidate)
|
||||||
|
if parsed.scheme in {"http", "https"} and parsed.netloc.lower() in _YOUTUBE_HOSTS:
|
||||||
|
return "youtube"
|
||||||
|
if parsed.scheme in {"http", "https"}:
|
||||||
|
return "url"
|
||||||
|
if Path(candidate).expanduser().exists():
|
||||||
|
return "local"
|
||||||
|
return source_type
|
||||||
|
|
||||||
|
|
||||||
|
def _is_caption_file(path: Path) -> bool:
|
||||||
|
return path.suffix.lower() in _CAPTION_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def _requires_audio_extraction(path: Path) -> bool:
|
||||||
|
return path.suffix.lower() in _VIDEO_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def _is_supported_remote_content(content_type: str, suffix: str) -> bool:
|
||||||
|
if suffix in _CAPTION_EXTENSIONS | _VIDEO_EXTENSIONS | _AUDIO_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
if content_type.startswith("video/") or content_type.startswith("audio/"):
|
||||||
|
return True
|
||||||
|
return content_type in {"text/plain", "text/vtt", "application/x-subrip", "application/octet-stream"}
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_suffix(source_url: str, content_type: str) -> str:
|
||||||
|
parsed = urlparse(source_url)
|
||||||
|
suffix = Path(parsed.path).suffix.lower()
|
||||||
|
if suffix:
|
||||||
|
return suffix
|
||||||
|
mapping = {
|
||||||
|
"video/mp4": ".mp4",
|
||||||
|
"video/quicktime": ".mov",
|
||||||
|
"audio/mpeg": ".mp3",
|
||||||
|
"audio/mp4": ".m4a",
|
||||||
|
"audio/x-m4a": ".m4a",
|
||||||
|
"text/plain": ".txt",
|
||||||
|
"text/vtt": ".vtt",
|
||||||
|
"application/x-subrip": ".srt",
|
||||||
|
}
|
||||||
|
return mapping.get(content_type, ".bin")
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_paths(paths: list[Path]) -> None:
|
||||||
|
seen: set[str] = set()
|
||||||
|
for path in paths:
|
||||||
|
raw = str(path)
|
||||||
|
if not raw or raw in seen:
|
||||||
|
continue
|
||||||
|
seen.add(raw)
|
||||||
|
if path.is_dir():
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ffmpeg_binary() -> str:
|
||||||
|
binary = shutil.which("ffmpeg")
|
||||||
|
if binary:
|
||||||
|
return binary
|
||||||
|
if imageio_ffmpeg is not None:
|
||||||
|
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||||
|
raise RuntimeError(
|
||||||
|
"ffmpeg is not installed. Install Homebrew ffmpeg or add `imageio-ffmpeg` to the Python environment."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_upload(upload: UploadFile, target_path: Path) -> None:
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
upload.file.seek(0)
|
||||||
|
with target_path.open("wb") as handle:
|
||||||
|
shutil.copyfileobj(upload.file, handle)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_caption_file(directory: Path) -> Path | None:
|
||||||
|
candidates = [path for path in directory.iterdir() if path.suffix.lower() in _CAPTION_EXTENSIONS]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _score(path: Path) -> tuple[int, str]:
|
||||||
|
name = path.name.lower()
|
||||||
|
if ".ko" in name:
|
||||||
|
return (0, name)
|
||||||
|
if ".en" in name:
|
||||||
|
return (1, name)
|
||||||
|
return (2, name)
|
||||||
|
|
||||||
|
return sorted(candidates, key=_score)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _language_from_filename(path: Path) -> str | None:
|
||||||
|
name = path.name.lower()
|
||||||
|
if ".ko" in name:
|
||||||
|
return "ko"
|
||||||
|
if ".en" in name:
|
||||||
|
return "en"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vtt_text(vtt_content: str) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
for raw_line in vtt_content.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line == "WEBVTT" or "-->" in line:
|
||||||
|
continue
|
||||||
|
if line.isdigit() or line.startswith(("NOTE", "Kind:", "Language:")):
|
||||||
|
continue
|
||||||
|
lines.append(line)
|
||||||
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_caption_text(content: str, suffix: str) -> str:
|
||||||
|
if suffix == ".vtt":
|
||||||
|
return _parse_vtt_text(content)
|
||||||
|
return _parse_srt_text(content)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> dict[str, Any] | None:
|
||||||
|
if not text or not text.strip():
|
||||||
|
return None
|
||||||
|
cleaned = re.sub(r"^```(?:json)?\s*", "", text.strip())
|
||||||
|
cleaned = re.sub(r"\s*```$", "", cleaned).strip()
|
||||||
|
try:
|
||||||
|
parsed = json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
match = re.search(r"\{.*\}", cleaned, flags=re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(match.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return parsed if isinstance(parsed, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _top_terms(text: str, limit: int = 6) -> list[str]:
|
||||||
|
counter: dict[str, int] = {}
|
||||||
|
for token in re.findall(r"[A-Za-z][A-Za-z\-]{3,}", text.lower()):
|
||||||
|
if token in _TEXT_STOPWORDS:
|
||||||
|
continue
|
||||||
|
counter[token] = counter.get(token, 0) + 1
|
||||||
|
ranked = sorted(counter.items(), key=lambda item: (-item[1], item[0]))
|
||||||
|
return [token for token, _count in ranked[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_analysis(text: str) -> dict[str, Any]:
|
||||||
|
excerpt = smart_chunk(text.strip(), max_chars=720)
|
||||||
|
positives = sum(text.lower().count(word) for word in _POSITIVE_WORDS)
|
||||||
|
negatives = sum(text.lower().count(word) for word in _NEGATIVE_WORDS)
|
||||||
|
if positives > negatives:
|
||||||
|
sentiment = "positive"
|
||||||
|
elif negatives > positives:
|
||||||
|
sentiment = "negative"
|
||||||
|
else:
|
||||||
|
sentiment = "neutral"
|
||||||
|
keywords = _top_terms(text, limit=6)
|
||||||
|
return {
|
||||||
|
"summary": excerpt or "Transcript extracted, but AI summary is unavailable.",
|
||||||
|
"keywords": keywords,
|
||||||
|
"topics": keywords[:4],
|
||||||
|
"sentiment": sentiment,
|
||||||
|
"intent": "Review transcript manually or add a Gemini API key for richer analysis.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _language_display_name(target_language: str) -> str:
|
||||||
|
return {
|
||||||
|
"ko": "Korean",
|
||||||
|
"ja": "Japanese",
|
||||||
|
"en": "English",
|
||||||
|
}.get(target_language.lower(), target_language.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_analysis(payload: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
keywords = payload.get("keywords")
|
||||||
|
topics = payload.get("topics")
|
||||||
|
sentiment = str(payload.get("sentiment") or fallback["sentiment"]).strip().lower()
|
||||||
|
if sentiment not in {"positive", "neutral", "negative"}:
|
||||||
|
sentiment = fallback["sentiment"]
|
||||||
|
return {
|
||||||
|
"summary": str(payload.get("summary") or fallback["summary"]).strip(),
|
||||||
|
"keywords": [str(item).strip() for item in (keywords if isinstance(keywords, list) else fallback["keywords"]) if str(item).strip()],
|
||||||
|
"topics": [str(item).strip() for item in (topics if isinstance(topics, list) else fallback["topics"]) if str(item).strip()],
|
||||||
|
"sentiment": sentiment,
|
||||||
|
"intent": str(payload.get("intent") or fallback["intent"]).strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_whisper_model() -> Any:
|
||||||
|
global _WHISPER_MODEL
|
||||||
|
if _WHISPER_MODEL is not None:
|
||||||
|
return _WHISPER_MODEL
|
||||||
|
if WhisperModel is None:
|
||||||
|
raise RuntimeError("faster-whisper is not installed. Run `pip install -r requirements.txt`.")
|
||||||
|
with _WHISPER_MODEL_LOCK:
|
||||||
|
if _WHISPER_MODEL is None:
|
||||||
|
_WHISPER_MODEL = WhisperModel(
|
||||||
|
os.getenv("WHISPER_MODEL_SIZE", "base"),
|
||||||
|
device="cpu",
|
||||||
|
compute_type=os.getenv("WHISPER_COMPUTE_TYPE", "int8"),
|
||||||
|
)
|
||||||
|
return _WHISPER_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
async def submit_job(
|
||||||
|
url_or_path: str,
|
||||||
|
source_type: Literal["youtube", "url", "local"],
|
||||||
|
*,
|
||||||
|
language: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
source_label: str | None = None,
|
||||||
|
cleanup_source: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Create a new transcript job and start asynchronous processing."""
|
||||||
|
|
||||||
|
resolved_source_type = _detect_source_type(url_or_path, source_type)
|
||||||
|
source_value = url_or_path.strip() if resolved_source_type != "local" else str(Path(url_or_path).expanduser())
|
||||||
|
job_id = uuid4().hex
|
||||||
|
await repo.add_video_job(job_id, source_value, resolved_source_type)
|
||||||
|
if source_label:
|
||||||
|
await repo.update_video_job(job_id, title=source_label)
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
_process_job(
|
||||||
|
job_id,
|
||||||
|
language=language,
|
||||||
|
api_key=(api_key or "").strip() or None,
|
||||||
|
source_label=source_label,
|
||||||
|
cleanup_source=cleanup_source,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_TASKS[job_id] = task
|
||||||
|
task.add_done_callback(lambda _: _TASKS.pop(job_id, None))
|
||||||
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
|
async def submit_upload(upload: UploadFile, *, language: str | None = None, api_key: str | None = None) -> str:
|
||||||
|
"""Persist a multipart upload locally and enqueue it as a transcript job."""
|
||||||
|
|
||||||
|
temp_dir = Path(tempfile.mkdtemp(prefix="atlas-video-upload-"))
|
||||||
|
suffix = Path(upload.filename or "upload.bin").suffix
|
||||||
|
target_path = temp_dir / f"upload{suffix}"
|
||||||
|
await asyncio.to_thread(_persist_upload, upload, target_path)
|
||||||
|
await upload.close()
|
||||||
|
return await submit_job(
|
||||||
|
str(target_path),
|
||||||
|
"local",
|
||||||
|
language=language,
|
||||||
|
api_key=api_key,
|
||||||
|
source_label=upload.filename or target_path.name,
|
||||||
|
cleanup_source=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
return await repo.list_video_jobs(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_job_detail(job_id: str) -> dict[str, Any] | None:
|
||||||
|
row = await repo.get_video_job(job_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"job": {
|
||||||
|
"job_id": row["job_id"],
|
||||||
|
"status": row["status"],
|
||||||
|
"source_url": row["source_url"],
|
||||||
|
"source_type": row["source_type"],
|
||||||
|
"progress": row.get("progress", 0),
|
||||||
|
"error": row.get("error"),
|
||||||
|
"title": row.get("title"),
|
||||||
|
"duration_sec": row.get("duration_sec"),
|
||||||
|
"language": row.get("language"),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"completed_at": row.get("completed_at"),
|
||||||
|
},
|
||||||
|
"transcript": None if not row.get("transcript_text") else {
|
||||||
|
"job_id": row["job_id"],
|
||||||
|
"text": row.get("transcript_text", ""),
|
||||||
|
"summary": row.get("summary"),
|
||||||
|
"keywords": row.get("keywords", []),
|
||||||
|
"topics": row.get("topics", []),
|
||||||
|
"sentiment": row.get("sentiment"),
|
||||||
|
"intent": row.get("intent"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def search_jobs(query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
return await repo.search_videos(query, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_job(job_id: str) -> bool:
|
||||||
|
task = _TASKS.pop(job_id, None)
|
||||||
|
if task is not None and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError, Exception):
|
||||||
|
await task
|
||||||
|
return await repo.delete_video_job(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def translate_job_content(
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
target_language: str = "ko",
|
||||||
|
api_key: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Translate a stored transcript payload on demand."""
|
||||||
|
|
||||||
|
row = await repo.get_video_job(job_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
transcript_text = (row.get("transcript_text") or "").strip()
|
||||||
|
if not transcript_text:
|
||||||
|
raise ValueError("Transcript text is not available yet.")
|
||||||
|
|
||||||
|
resolved_api_key = _resolved_gemini_api_key(api_key)
|
||||||
|
if not resolved_api_key:
|
||||||
|
raise RuntimeError("Set a Gemini API key in Settings to use Korean translation.")
|
||||||
|
|
||||||
|
target_label = _language_display_name(target_language)
|
||||||
|
translated_text = await _translate_text_chunks(
|
||||||
|
transcript_text,
|
||||||
|
target_language=target_language,
|
||||||
|
api_key=resolved_api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
source_payload = {
|
||||||
|
"summary": row.get("summary") or "",
|
||||||
|
"keywords": row.get("keywords", []),
|
||||||
|
"topics": row.get("topics", []),
|
||||||
|
"intent": row.get("intent") or "",
|
||||||
|
}
|
||||||
|
meta_prompt = (
|
||||||
|
f"Translate the following analyst metadata into natural {target_label}.\n"
|
||||||
|
"Return ONLY valid JSON with this exact shape:\n"
|
||||||
|
'{"summary":"...","keywords":["..."],"topics":["..."],"intent":"..."}\n'
|
||||||
|
"- Preserve company names, proper nouns, and figures.\n"
|
||||||
|
"- Translate keywords and topics into concise Korean finance/media terms.\n"
|
||||||
|
"- Do not add markdown fences or commentary.\n\n"
|
||||||
|
f"SOURCE_JSON:\n{json.dumps(source_payload, ensure_ascii=False)}"
|
||||||
|
)
|
||||||
|
translated_meta = _extract_json_object(
|
||||||
|
await generate_text(meta_prompt, temperature=0.2, max_tokens=1200, api_key=resolved_api_key)
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
translated_summary = str(translated_meta.get("summary") or "").strip()
|
||||||
|
if not translated_summary:
|
||||||
|
translated_summary = await _translate_text_chunks(
|
||||||
|
source_payload["summary"] or smart_chunk(transcript_text, max_chars=1500),
|
||||||
|
target_language=target_language,
|
||||||
|
api_key=resolved_api_key,
|
||||||
|
max_chars=4_500,
|
||||||
|
min_chunk=1_200,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"job_id": job_id,
|
||||||
|
"target_language": target_language,
|
||||||
|
"summary": translated_summary,
|
||||||
|
"keywords": [str(item).strip() for item in (translated_meta.get("keywords") or source_payload["keywords"]) if str(item).strip()],
|
||||||
|
"topics": [str(item).strip() for item in (translated_meta.get("topics") or source_payload["topics"]) if str(item).strip()],
|
||||||
|
"intent": str(translated_meta.get("intent") or source_payload["intent"]).strip(),
|
||||||
|
"text": translated_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_job(
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
language: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
source_label: str | None = None,
|
||||||
|
cleanup_source: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Resolve source media, extract transcript text, run analysis, and persist results."""
|
||||||
|
|
||||||
|
row = await repo.get_video_job(job_id)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
cleanup_paths: list[Path] = []
|
||||||
|
try:
|
||||||
|
source_url = row["source_url"]
|
||||||
|
source_type = _detect_source_type(source_url, row["source_type"])
|
||||||
|
await repo.update_video_job(job_id, source_type=source_type, status="fetching", progress=15, error=None)
|
||||||
|
|
||||||
|
if source_type == "youtube":
|
||||||
|
cached = await repo.cache_get(_cache_key(source_url))
|
||||||
|
if isinstance(cached, dict) and cached.get("transcript_text"):
|
||||||
|
await repo.update_video_job(
|
||||||
|
job_id,
|
||||||
|
title=source_label or cached.get("title"),
|
||||||
|
duration_sec=cached.get("duration_sec"),
|
||||||
|
language=cached.get("language"),
|
||||||
|
transcript_text=cached.get("transcript_text"),
|
||||||
|
summary=cached.get("summary"),
|
||||||
|
keywords=cached.get("keywords", []),
|
||||||
|
topics=cached.get("topics", []),
|
||||||
|
sentiment=cached.get("sentiment"),
|
||||||
|
intent=cached.get("intent"),
|
||||||
|
status="completed",
|
||||||
|
progress=100,
|
||||||
|
completed_at=_now_iso(),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
material = await _fetch_youtube_source(source_url, preferred_language=language)
|
||||||
|
elif source_type == "local":
|
||||||
|
material = await _prepare_local_source(source_url, owned_source=cleanup_source)
|
||||||
|
else:
|
||||||
|
material = await _fetch_remote_source(source_url)
|
||||||
|
|
||||||
|
cleanup_paths.extend(material.cleanup_paths)
|
||||||
|
title = source_label or material.title or row.get("title") or _display_name_from_source(source_url)
|
||||||
|
detected_language = material.language or language
|
||||||
|
duration_sec = material.duration_sec
|
||||||
|
|
||||||
|
await repo.update_video_job(
|
||||||
|
job_id,
|
||||||
|
title=title,
|
||||||
|
duration_sec=duration_sec,
|
||||||
|
language=detected_language,
|
||||||
|
status="transcribing",
|
||||||
|
progress=45,
|
||||||
|
)
|
||||||
|
|
||||||
|
transcript_text = (material.transcript_text or "").strip()
|
||||||
|
if not transcript_text:
|
||||||
|
if material.media_path is None:
|
||||||
|
raise RuntimeError("No subtitle track or downloadable media was found for this source.")
|
||||||
|
transcribe_path = material.media_path
|
||||||
|
if _requires_audio_extraction(material.media_path):
|
||||||
|
transcribe_path = await asyncio.to_thread(_extract_audio_track, material.media_path)
|
||||||
|
cleanup_paths.append(transcribe_path)
|
||||||
|
whisper_result = await asyncio.to_thread(_whisper_transcribe, str(transcribe_path), language)
|
||||||
|
transcript_text = (whisper_result.get("text") or "").strip()
|
||||||
|
detected_language = whisper_result.get("language") or detected_language
|
||||||
|
duration_value = whisper_result.get("duration_sec") or duration_sec
|
||||||
|
duration_sec = int(round(duration_value)) if duration_value else duration_sec
|
||||||
|
|
||||||
|
if not transcript_text:
|
||||||
|
raise RuntimeError("Transcript extraction finished, but no text was produced.")
|
||||||
|
|
||||||
|
await repo.update_video_job(
|
||||||
|
job_id,
|
||||||
|
title=title,
|
||||||
|
duration_sec=duration_sec,
|
||||||
|
language=detected_language,
|
||||||
|
transcript_text=transcript_text,
|
||||||
|
progress=65,
|
||||||
|
)
|
||||||
|
|
||||||
|
await repo.update_video_job(job_id, status="analyzing", progress=80)
|
||||||
|
analysis = await _analyze_text(transcript_text, api_key=api_key, title=title)
|
||||||
|
completed_at = _now_iso()
|
||||||
|
await repo.update_video_job(
|
||||||
|
job_id,
|
||||||
|
status="completed",
|
||||||
|
progress=100,
|
||||||
|
completed_at=completed_at,
|
||||||
|
error=None,
|
||||||
|
title=title,
|
||||||
|
duration_sec=duration_sec,
|
||||||
|
language=detected_language,
|
||||||
|
transcript_text=transcript_text,
|
||||||
|
summary=analysis["summary"],
|
||||||
|
keywords=analysis["keywords"],
|
||||||
|
topics=analysis["topics"],
|
||||||
|
sentiment=analysis["sentiment"],
|
||||||
|
intent=analysis["intent"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_type == "youtube":
|
||||||
|
await repo.cache_set(
|
||||||
|
_cache_key(source_url),
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"duration_sec": duration_sec,
|
||||||
|
"language": detected_language,
|
||||||
|
"transcript_text": transcript_text,
|
||||||
|
"summary": analysis["summary"],
|
||||||
|
"keywords": analysis["keywords"],
|
||||||
|
"topics": analysis["topics"],
|
||||||
|
"sentiment": analysis["sentiment"],
|
||||||
|
"intent": analysis["intent"],
|
||||||
|
"completed_at": completed_at,
|
||||||
|
},
|
||||||
|
ttl=_YOUTUBE_CACHE_TTL,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Video transcript job %s cancelled.", job_id)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Video transcript job %s failed", job_id)
|
||||||
|
await repo.update_video_job(
|
||||||
|
job_id,
|
||||||
|
status="failed",
|
||||||
|
progress=100,
|
||||||
|
error=str(exc),
|
||||||
|
completed_at=_now_iso(),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(_cleanup_paths, cleanup_paths)
|
||||||
|
|
||||||
|
|
||||||
|
async def _prepare_local_source(source_path: str, *, owned_source: bool = False) -> SourceMaterial:
|
||||||
|
path = Path(source_path).expanduser()
|
||||||
|
if not path.exists():
|
||||||
|
raise RuntimeError(f"Local file not found: {path}")
|
||||||
|
cleanup_paths = [path.parent] if owned_source else []
|
||||||
|
if _is_caption_file(path):
|
||||||
|
content = await asyncio.to_thread(path.read_text, encoding="utf-8", errors="ignore")
|
||||||
|
return SourceMaterial(
|
||||||
|
title=path.name,
|
||||||
|
language=_language_from_filename(path),
|
||||||
|
transcript_text=_parse_caption_text(content, path.suffix.lower()),
|
||||||
|
cleanup_paths=cleanup_paths,
|
||||||
|
)
|
||||||
|
return SourceMaterial(
|
||||||
|
title=path.name,
|
||||||
|
media_path=path,
|
||||||
|
cleanup_paths=cleanup_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_remote_source(source_url: str) -> SourceMaterial:
|
||||||
|
if _detect_source_type(source_url, "url") == "youtube":
|
||||||
|
return await _fetch_youtube_source(source_url)
|
||||||
|
|
||||||
|
temp_dir = Path(tempfile.mkdtemp(prefix="atlas-video-remote-"))
|
||||||
|
content_type = ""
|
||||||
|
target_path = temp_dir / "download.bin"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(60.0, connect=20.0)) as client:
|
||||||
|
async with client.stream("GET", source_url) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
content_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||||
|
content_length = int(response.headers.get("content-length") or 0)
|
||||||
|
if content_length and content_length > _REMOTE_MAX_BYTES:
|
||||||
|
raise RuntimeError("Remote media is larger than the 500MB safety limit.")
|
||||||
|
suffix = _infer_suffix(source_url, content_type)
|
||||||
|
if not _is_supported_remote_content(content_type, suffix):
|
||||||
|
raise RuntimeError(f"Unsupported remote content type: {content_type or suffix}")
|
||||||
|
target_path = temp_dir / f"download{suffix}"
|
||||||
|
total_bytes = 0
|
||||||
|
with target_path.open("wb") as handle:
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
total_bytes += len(chunk)
|
||||||
|
if total_bytes > _REMOTE_MAX_BYTES:
|
||||||
|
raise RuntimeError("Remote media exceeded the 500MB safety limit while downloading.")
|
||||||
|
handle.write(chunk)
|
||||||
|
except Exception:
|
||||||
|
await asyncio.to_thread(_cleanup_paths, [temp_dir])
|
||||||
|
raise
|
||||||
|
|
||||||
|
if _is_caption_file(target_path):
|
||||||
|
content = await asyncio.to_thread(target_path.read_text, encoding="utf-8", errors="ignore")
|
||||||
|
return SourceMaterial(
|
||||||
|
title=_display_name_from_source(source_url),
|
||||||
|
language=_language_from_filename(target_path),
|
||||||
|
transcript_text=_parse_caption_text(content, target_path.suffix.lower()),
|
||||||
|
cleanup_paths=[temp_dir],
|
||||||
|
)
|
||||||
|
|
||||||
|
return SourceMaterial(
|
||||||
|
title=_display_name_from_source(source_url),
|
||||||
|
media_path=target_path,
|
||||||
|
cleanup_paths=[temp_dir],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_youtube_source(source_url: str, preferred_language: str | None = None) -> SourceMaterial:
|
||||||
|
temp_dir = Path(tempfile.mkdtemp(prefix="atlas-video-youtube-"))
|
||||||
|
try:
|
||||||
|
info: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
info = await asyncio.to_thread(_download_youtube_subtitles, source_url, temp_dir, preferred_language)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("YouTube subtitle download failed for %s; falling back to audio transcription.", source_url, exc_info=True)
|
||||||
|
title = str(info.get("title") or _display_name_from_source(source_url))
|
||||||
|
duration_sec = int(info.get("duration")) if info.get("duration") else None
|
||||||
|
caption_path = _pick_caption_file(temp_dir)
|
||||||
|
if caption_path is not None:
|
||||||
|
content = await asyncio.to_thread(caption_path.read_text, encoding="utf-8", errors="ignore")
|
||||||
|
return SourceMaterial(
|
||||||
|
title=title,
|
||||||
|
duration_sec=duration_sec,
|
||||||
|
language=_language_from_filename(caption_path),
|
||||||
|
transcript_text=_parse_caption_text(content, caption_path.suffix.lower()),
|
||||||
|
cleanup_paths=[temp_dir],
|
||||||
|
)
|
||||||
|
|
||||||
|
media_path, audio_info = await asyncio.to_thread(_download_youtube_audio, source_url, temp_dir)
|
||||||
|
return SourceMaterial(
|
||||||
|
title=str(audio_info.get("title") or title),
|
||||||
|
duration_sec=int(audio_info.get("duration")) if audio_info.get("duration") else duration_sec,
|
||||||
|
media_path=media_path,
|
||||||
|
cleanup_paths=[temp_dir],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await asyncio.to_thread(_cleanup_paths, [temp_dir])
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_subtitle_languages(preferred_language: str | None = None) -> list[str]:
|
||||||
|
ordered: list[str] = []
|
||||||
|
for value in [preferred_language, "en", "ko", "en-US", "ko-KR"]:
|
||||||
|
candidate = (value or "").strip()
|
||||||
|
if candidate and candidate not in ordered:
|
||||||
|
ordered.append(candidate)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _download_youtube_subtitles(source_url: str, output_dir: Path, preferred_language: str | None = None) -> dict[str, Any]:
|
||||||
|
if YoutubeDL is None:
|
||||||
|
raise RuntimeError("yt-dlp is not installed. Run `pip install -r requirements.txt`.")
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for language in _normalise_subtitle_languages(preferred_language):
|
||||||
|
options = {
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
"noprogress": True,
|
||||||
|
"noplaylist": True,
|
||||||
|
"skip_download": True,
|
||||||
|
"writesubtitles": True,
|
||||||
|
"writeautomaticsub": True,
|
||||||
|
"subtitleslangs": [language],
|
||||||
|
"subtitlesformat": "srt/vtt/best",
|
||||||
|
"outtmpl": str(output_dir / "%(id)s.%(ext)s"),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with YoutubeDL(options) as ydl:
|
||||||
|
info = ydl.extract_info(source_url, download=True)
|
||||||
|
if _pick_caption_file(output_dir) is not None:
|
||||||
|
return info
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
logger.warning("Subtitle attempt failed for language %s on %s", language, source_url, exc_info=True)
|
||||||
|
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _download_youtube_audio(source_url: str, output_dir: Path) -> tuple[Path, dict[str, Any]]:
|
||||||
|
if YoutubeDL is None:
|
||||||
|
raise RuntimeError("yt-dlp is not installed. Run `pip install -r requirements.txt`.")
|
||||||
|
options = {
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
"noprogress": True,
|
||||||
|
"noplaylist": True,
|
||||||
|
"format": "bestaudio[ext=m4a]/bestaudio/best",
|
||||||
|
"outtmpl": str(output_dir / "%(id)s.%(ext)s"),
|
||||||
|
}
|
||||||
|
with YoutubeDL(options) as ydl:
|
||||||
|
info = ydl.extract_info(source_url, download=True)
|
||||||
|
return Path(ydl.prepare_filename(info)), info
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_audio_track(media_path: Path) -> Path:
|
||||||
|
if ffmpeg is None:
|
||||||
|
raise RuntimeError("ffmpeg-python is not installed. Run `pip install -r requirements.txt`.")
|
||||||
|
output_path = media_path.parent / f"{media_path.stem}.atlas.wav"
|
||||||
|
ffmpeg_binary = _resolve_ffmpeg_binary()
|
||||||
|
try:
|
||||||
|
stream = ffmpeg.input(str(media_path))
|
||||||
|
stream = ffmpeg.output(stream, str(output_path), acodec="pcm_s16le", ac=1, ar="16000", format="wav")
|
||||||
|
ffmpeg.run(stream.overwrite_output(), cmd=ffmpeg_binary, capture_stdout=True, capture_stderr=True)
|
||||||
|
except ffmpeg.Error as exc: # type: ignore[attr-defined]
|
||||||
|
stderr = exc.stderr.decode("utf-8", errors="ignore") if getattr(exc, "stderr", None) else str(exc)
|
||||||
|
raise RuntimeError(f"ffmpeg failed to extract audio: {stderr.strip()}") from exc
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def _whisper_transcribe(audio_path: str, language: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Transcribe audio or video with a lazily loaded faster-whisper model."""
|
||||||
|
|
||||||
|
model = _get_whisper_model()
|
||||||
|
segments, info = model.transcribe(audio_path, language=language, vad_filter=True, beam_size=5)
|
||||||
|
parsed_segments: list[dict[str, Any]] = []
|
||||||
|
for segment in segments:
|
||||||
|
text = (segment.text or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
parsed_segments.append(
|
||||||
|
{
|
||||||
|
"start": round(float(segment.start), 2),
|
||||||
|
"end": round(float(segment.end), 2),
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
full_text = " ".join(segment["text"] for segment in parsed_segments).strip()
|
||||||
|
duration_sec = getattr(info, "duration", None) or (parsed_segments[-1]["end"] if parsed_segments else 0)
|
||||||
|
return {
|
||||||
|
"text": full_text,
|
||||||
|
"language": getattr(info, "language", None) or language,
|
||||||
|
"duration_sec": duration_sec,
|
||||||
|
"segments": parsed_segments,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_srt_text(srt_content: str) -> str:
|
||||||
|
"""Strip timing metadata and join subtitle text into a clean transcript."""
|
||||||
|
|
||||||
|
if not srt_content.strip():
|
||||||
|
return ""
|
||||||
|
if srt is None:
|
||||||
|
cleaned = re.sub(r"\d+\s+\d{2}:\d{2}:\d{2},\d{3}\s+-->\s+\d{2}:\d{2}:\d{2},\d{3}", "", srt_content)
|
||||||
|
cleaned = re.sub(r"^\d+\s*$", "", cleaned, flags=re.MULTILINE)
|
||||||
|
return "\n".join(line.strip() for line in cleaned.splitlines() if line.strip())
|
||||||
|
|
||||||
|
subtitles = []
|
||||||
|
for item in srt.parse(srt_content):
|
||||||
|
text = " ".join(line.strip() for line in item.content.splitlines() if line.strip()).strip()
|
||||||
|
if text:
|
||||||
|
subtitles.append(text)
|
||||||
|
return "\n".join(subtitles)
|
||||||
|
|
||||||
|
|
||||||
|
async def _analyze_text(text: str, *, api_key: str | None = None, title: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Generate a summary, topics, and sentiment for a transcript."""
|
||||||
|
|
||||||
|
fallback = _fallback_analysis(text)
|
||||||
|
chunk_candidates = _split_into_chunks(text, max_chars=10_000, min_chunk=3_000)
|
||||||
|
chunks = [smart_chunk(chunk, max_chars=10_000) for chunk in chunk_candidates if chunk.strip()] or [smart_chunk(text, max_chars=10_000)]
|
||||||
|
|
||||||
|
try:
|
||||||
|
partial_summaries: list[str] = []
|
||||||
|
for index, chunk in enumerate(chunks, start=1):
|
||||||
|
prompt = (
|
||||||
|
"You are preparing analyst notes from one transcript chunk.\n"
|
||||||
|
f"Chunk {index} of {len(chunks)} for: {title or 'Untitled media'}\n\n"
|
||||||
|
"Return 4-6 concise bullet points covering factual takeaways, recurring themes, risks, and speaker tone.\n"
|
||||||
|
"Do not invent facts. Keep names, products, and figures exact when present.\n\n"
|
||||||
|
f"TRANSCRIPT CHUNK:\n{chunk}"
|
||||||
|
)
|
||||||
|
partial_summaries.append(
|
||||||
|
await generate_text(prompt, temperature=0.2, max_tokens=700, api_key=api_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_blob = "\n\n".join(f"Chunk {idx} notes:\n{summary.strip()}" for idx, summary in enumerate(partial_summaries, start=1))
|
||||||
|
head_sample = smart_chunk(text[:10_000], max_chars=2_000)
|
||||||
|
tail_sample = smart_chunk(text[-10_000:], max_chars=2_000)
|
||||||
|
final_prompt = (
|
||||||
|
"You are an expert media and transcript analyst.\n"
|
||||||
|
"Use the chunk notes and transcript samples below.\n"
|
||||||
|
"Return ONLY valid JSON with this exact shape:\n"
|
||||||
|
'{"summary":"...","keywords":["..."],"topics":["..."],"sentiment":"positive|neutral|negative","intent":"..."}\n'
|
||||||
|
"Rules:\n"
|
||||||
|
"- summary: 3-5 sentences, specific and factual\n"
|
||||||
|
"- keywords: 5-8 concise phrases\n"
|
||||||
|
"- topics: 3-5 broader themes\n"
|
||||||
|
"- sentiment: choose only positive, neutral, or negative\n"
|
||||||
|
"- intent: one concise sentence describing what the speaker or content is trying to achieve\n"
|
||||||
|
"- Do not add markdown fences or commentary.\n\n"
|
||||||
|
f"TITLE: {title or 'Untitled media'}\n\n"
|
||||||
|
f"CHUNK NOTES:\n{summary_blob}\n\n"
|
||||||
|
f"HEAD SAMPLE:\n{head_sample}\n\n"
|
||||||
|
f"TAIL SAMPLE:\n{tail_sample}"
|
||||||
|
)
|
||||||
|
parsed = _extract_json_object(
|
||||||
|
await generate_text(final_prompt, temperature=0.2, max_tokens=1200, api_key=api_key)
|
||||||
|
)
|
||||||
|
if parsed is None:
|
||||||
|
return fallback
|
||||||
|
return _normalise_analysis(parsed, fallback)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Falling back to heuristic transcript analysis.", exc_info=True)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
async def _translate_text_chunks(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
target_language: str,
|
||||||
|
api_key: str,
|
||||||
|
max_chars: int = 7_000,
|
||||||
|
min_chunk: int = 2_000,
|
||||||
|
) -> str:
|
||||||
|
"""Translate long text in chunks while preserving structure."""
|
||||||
|
|
||||||
|
source = text.strip()
|
||||||
|
if not source:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
target_label = _language_display_name(target_language)
|
||||||
|
chunks = _split_into_chunks(source, max_chars=max_chars, min_chunk=min_chunk) or [source]
|
||||||
|
translated_chunks: list[str] = []
|
||||||
|
for index, chunk in enumerate(chunks, start=1):
|
||||||
|
prompt = (
|
||||||
|
f"Translate the following transcript chunk into natural {target_label}.\n"
|
||||||
|
"- Preserve meaning, numbers, names, and paragraph breaks.\n"
|
||||||
|
"- Keep speaker turns and emphasis where obvious.\n"
|
||||||
|
"- Return ONLY the translated text.\n"
|
||||||
|
f"- This is chunk {index} of {len(chunks)}.\n\n"
|
||||||
|
f"TRANSCRIPT CHUNK:\n{chunk}"
|
||||||
|
)
|
||||||
|
translated_chunks.append(
|
||||||
|
(await generate_text(prompt, temperature=0.2, max_tokens=2200, api_key=api_key)).strip()
|
||||||
|
)
|
||||||
|
return "\n\n".join(chunk for chunk in translated_chunks if chunk)
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Tests for the video transcript service and repository integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from server.db.unified_repo import repo
|
||||||
|
from server.services import video_transcript_service as service
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_srt_text_strips_timestamps() -> None:
|
||||||
|
raw_srt = """1
|
||||||
|
00:00:00,000 --> 00:00:01,500
|
||||||
|
Welcome to the call.
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:01,500 --> 00:00:03,000
|
||||||
|
Revenue grew 12%.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parsed = service._parse_srt_text(raw_srt)
|
||||||
|
|
||||||
|
assert "00:00:00,000" not in parsed
|
||||||
|
assert parsed == "Welcome to the call.\nRevenue grew 12%."
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_text_runs_chunk_map_reduce(monkeypatch) -> None:
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def fake_generate_text(prompt: str, **_: object) -> str:
|
||||||
|
calls.append(prompt)
|
||||||
|
if len(calls) < 3:
|
||||||
|
return f"- chunk note {len(calls)}"
|
||||||
|
return (
|
||||||
|
'{"summary":"Management highlighted revenue growth.",'
|
||||||
|
'"keywords":["revenue growth","guidance"],'
|
||||||
|
'"topics":["Earnings"],'
|
||||||
|
'"sentiment":"positive",'
|
||||||
|
'"intent":"Reassure investors about momentum."}'
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "generate_text", fake_generate_text)
|
||||||
|
monkeypatch.setattr(service, "_split_into_chunks", lambda text, max_chars=10_000, min_chunk=3_000: ["chunk one", "chunk two"])
|
||||||
|
|
||||||
|
result = asyncio.run(service._analyze_text("long transcript body", api_key="test-key", title="Demo call"))
|
||||||
|
|
||||||
|
assert result["summary"] == "Management highlighted revenue growth."
|
||||||
|
assert result["keywords"] == ["revenue growth", "guidance"]
|
||||||
|
assert len(calls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_job_processes_statuses_to_completion(monkeypatch) -> None:
|
||||||
|
async def run_test() -> None:
|
||||||
|
await repo.init_db()
|
||||||
|
statuses: list[str] = []
|
||||||
|
original_update = service.repo.update_video_job
|
||||||
|
|
||||||
|
async def recording_update(job_id: str, **fields: object):
|
||||||
|
if "status" in fields:
|
||||||
|
statuses.append(str(fields["status"]))
|
||||||
|
return await original_update(job_id, **fields)
|
||||||
|
|
||||||
|
async def fake_prepare_local_source(source_path: str, *, owned_source: bool = False) -> service.SourceMaterial:
|
||||||
|
return service.SourceMaterial(
|
||||||
|
title="demo.mp4",
|
||||||
|
duration_sec=96,
|
||||||
|
language="en",
|
||||||
|
transcript_text="Revenue grew strongly and management stayed confident about AI demand.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_analyze_text(text: str, *, api_key: str | None = None, title: str | None = None):
|
||||||
|
return {
|
||||||
|
"summary": "Revenue and AI demand were the main focus.",
|
||||||
|
"keywords": ["revenue", "ai demand"],
|
||||||
|
"topics": ["Earnings"],
|
||||||
|
"sentiment": "positive",
|
||||||
|
"intent": "Reassure investors about execution.",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(service.repo, "update_video_job", recording_update)
|
||||||
|
monkeypatch.setattr(service, "_prepare_local_source", fake_prepare_local_source)
|
||||||
|
monkeypatch.setattr(service, "_analyze_text", fake_analyze_text)
|
||||||
|
|
||||||
|
job_id = await service.submit_job("/tmp/demo.mp4", "local", api_key="test-key", source_label="demo.mp4")
|
||||||
|
await service._TASKS[job_id]
|
||||||
|
row = await repo.get_video_job(job_id)
|
||||||
|
|
||||||
|
assert row is not None
|
||||||
|
assert row["status"] == "completed"
|
||||||
|
assert row["progress"] == 100
|
||||||
|
assert row["summary"] == "Revenue and AI demand were the main focus."
|
||||||
|
assert statuses == ["fetching", "transcribing", "analyzing", "completed"]
|
||||||
|
|
||||||
|
asyncio.run(run_test())
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_fts_search_returns_snippet() -> None:
|
||||||
|
async def run_test() -> None:
|
||||||
|
await repo.init_db()
|
||||||
|
await repo.add_video_job("job-1", "https://example.com/earnings", "url")
|
||||||
|
await repo.update_video_job(
|
||||||
|
"job-1",
|
||||||
|
title="Q1 Earnings Call",
|
||||||
|
transcript_text="Management discussed AI revenue, guidance, and margin expansion in detail.",
|
||||||
|
summary="AI revenue and guidance dominated the discussion.",
|
||||||
|
)
|
||||||
|
await repo.add_video_job("job-2", "https://example.com/interview", "url")
|
||||||
|
await repo.update_video_job(
|
||||||
|
"job-2",
|
||||||
|
title="CEO Interview",
|
||||||
|
transcript_text="Consumer demand remained steady and product cadence was unchanged.",
|
||||||
|
)
|
||||||
|
|
||||||
|
hits = await repo.search_videos("guidance", limit=10)
|
||||||
|
|
||||||
|
assert hits
|
||||||
|
assert hits[0]["job_id"] == "job-1"
|
||||||
|
assert "guidance" in hits[0]["snippet"].lower()
|
||||||
|
assert hits[0]["rank"] > 0
|
||||||
|
|
||||||
|
asyncio.run(run_test())
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_job_content_translates_meta_and_chunks(monkeypatch) -> None:
|
||||||
|
async def run_test() -> None:
|
||||||
|
await repo.init_db()
|
||||||
|
await repo.add_video_job("job-ko", "https://example.com/demo", "url")
|
||||||
|
await repo.update_video_job(
|
||||||
|
"job-ko",
|
||||||
|
transcript_text="first chunk\n\nsecond chunk",
|
||||||
|
summary="Original summary.",
|
||||||
|
keywords=["guidance", "margin"],
|
||||||
|
topics=["Earnings"],
|
||||||
|
intent="Reassure investors.",
|
||||||
|
status="completed",
|
||||||
|
progress=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
prompts: list[str] = []
|
||||||
|
|
||||||
|
async def fake_generate_text(prompt: str, **_: object) -> str:
|
||||||
|
prompts.append(prompt)
|
||||||
|
if "SOURCE_JSON" in prompt:
|
||||||
|
return (
|
||||||
|
'{"summary":"번역된 요약","keywords":["가이던스","마진"],'
|
||||||
|
'"topics":["실적"],"intent":"투자자를 안심시키려는 목적입니다."}'
|
||||||
|
)
|
||||||
|
if "chunk 1 of 2" in prompt:
|
||||||
|
return "첫 번째 청크"
|
||||||
|
if "chunk 2 of 2" in prompt:
|
||||||
|
return "두 번째 청크"
|
||||||
|
return "대체 요약"
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "generate_text", fake_generate_text)
|
||||||
|
monkeypatch.setattr(service, "_split_into_chunks", lambda text, max_chars=7000, min_chunk=2000: ["first chunk", "second chunk"])
|
||||||
|
|
||||||
|
translated = await service.translate_job_content("job-ko", target_language="ko", api_key="test-key")
|
||||||
|
|
||||||
|
assert translated is not None
|
||||||
|
assert translated["summary"] == "번역된 요약"
|
||||||
|
assert translated["keywords"] == ["가이던스", "마진"]
|
||||||
|
assert translated["text"] == "첫 번째 청크\n\n두 번째 청크"
|
||||||
|
assert len(prompts) == 3
|
||||||
|
|
||||||
|
asyncio.run(run_test())
|
||||||
Reference in New Issue
Block a user