Spaces:
Runtime error
Runtime error
Commit ·
054a1b5
1
Parent(s): 851f234
prompt engineering improvements
Browse files- backend/app.py +3 -3
- backend/helpers/transcript.py +12 -1
- backend/ollama.py +9 -9
- frontend/src/App.jsx +41 -31
- frontend/src/hooks/useStreaming.js +24 -2
backend/app.py
CHANGED
|
@@ -157,7 +157,7 @@ async def summarize_transcript(
|
|
| 157 |
verify_api_key(x_api_key)
|
| 158 |
if not request.text.strip():
|
| 159 |
raise HTTPException(status_code=400, detail="Text must not be empty.")
|
| 160 |
-
return stream_summary(request.text, title=request.title, model=request.model)
|
| 161 |
|
| 162 |
|
| 163 |
@app.post("/summarize/youtube")
|
|
@@ -167,7 +167,7 @@ async def summarize_youtube(
|
|
| 167 |
):
|
| 168 |
verify_api_key(x_api_key)
|
| 169 |
title, text = await fetch_yt_transcript(request.url)
|
| 170 |
-
return stream_summary(text, title=title, model=request.model)
|
| 171 |
|
| 172 |
|
| 173 |
@app.post("/summarize/file")
|
|
@@ -196,7 +196,7 @@ async def summarize_file(
|
|
| 196 |
|
| 197 |
if not text.strip():
|
| 198 |
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
| 199 |
-
return stream_summary(text, title=file.filename, model=model)
|
| 200 |
|
| 201 |
|
| 202 |
if __name__ == "__main__":
|
|
|
|
| 157 |
verify_api_key(x_api_key)
|
| 158 |
if not request.text.strip():
|
| 159 |
raise HTTPException(status_code=400, detail="Text must not be empty.")
|
| 160 |
+
return await stream_summary(request.text, title=request.title, model=request.model)
|
| 161 |
|
| 162 |
|
| 163 |
@app.post("/summarize/youtube")
|
|
|
|
| 167 |
):
|
| 168 |
verify_api_key(x_api_key)
|
| 169 |
title, text = await fetch_yt_transcript(request.url)
|
| 170 |
+
return await stream_summary(text, title=title, model=request.model)
|
| 171 |
|
| 172 |
|
| 173 |
@app.post("/summarize/file")
|
|
|
|
| 196 |
|
| 197 |
if not text.strip():
|
| 198 |
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
| 199 |
+
return await stream_summary(text, title=file.filename, model=model)
|
| 200 |
|
| 201 |
|
| 202 |
if __name__ == "__main__":
|
backend/helpers/transcript.py
CHANGED
|
@@ -120,6 +120,17 @@ async def transcript(url: str) -> tuple[Optional[str], str]:
|
|
| 120 |
# fetch runs on the event loop — both in parallel.
|
| 121 |
title_task = asyncio.create_task(_fetch_title(video_id))
|
| 122 |
text = await asyncio.to_thread(_fetch_transcript_sync, video_id)
|
| 123 |
-
title = await title_task
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
return title, text
|
|
|
|
| 120 |
# fetch runs on the event loop — both in parallel.
|
| 121 |
title_task = asyncio.create_task(_fetch_title(video_id))
|
| 122 |
text = await asyncio.to_thread(_fetch_transcript_sync, video_id)
|
|
|
|
| 123 |
|
| 124 |
+
# Give the title at most 1 extra second after the transcript is done.
|
| 125 |
+
# oEmbed can be slow; we never want it to be the bottleneck.
|
| 126 |
+
try:
|
| 127 |
+
title = await asyncio.wait_for(asyncio.shield(title_task), timeout=1.0)
|
| 128 |
+
except (asyncio.TimeoutError, Exception):
|
| 129 |
+
title_task.cancel()
|
| 130 |
+
title = None
|
| 131 |
+
|
| 132 |
+
# Return the fetched title (or None if unavailable) and the transcript text.
|
| 133 |
+
# Callers (e.g., backend/app.py) forward the title to `stream_summary`, which includes it in the prompt
|
| 134 |
+
# via `build_prompt`. If `title` is None, the prompt omits the title block, which is appropriate for
|
| 135 |
+
# plain‑text or other non‑title scenarios.
|
| 136 |
return title, text
|
backend/ollama.py
CHANGED
|
@@ -47,15 +47,15 @@ def build_prompt(title: Optional[str], text: str) -> str:
|
|
| 47 |
# Cache for installed models to avoid repeated network calls
|
| 48 |
_installed_models_cache: Optional[list] = None
|
| 49 |
|
| 50 |
-
def resolve_model(model: Optional[str]) -> str:
|
| 51 |
requested = model or ""
|
| 52 |
|
| 53 |
# Prefer what Ollama actually has installed, using cache to avoid repeated network calls.
|
| 54 |
global _installed_models_cache
|
| 55 |
if _installed_models_cache is None:
|
| 56 |
try:
|
| 57 |
-
with httpx.
|
| 58 |
-
r = client.get(f"{OLLAMA_BASE_URL}/api/tags")
|
| 59 |
r.raise_for_status()
|
| 60 |
payload = r.json() if r.content else {}
|
| 61 |
_installed_models_cache = [m.get("name") for m in payload.get("models", []) if m.get("name")]
|
|
@@ -87,10 +87,10 @@ def resolve_model(model: Optional[str]) -> str:
|
|
| 87 |
return requested
|
| 88 |
|
| 89 |
|
| 90 |
-
def ensure_ollama_reachable() -> None:
|
| 91 |
try:
|
| 92 |
-
with httpx.
|
| 93 |
-
response = client.get(f"{OLLAMA_BASE_URL}/api/tags")
|
| 94 |
response.raise_for_status()
|
| 95 |
except httpx.ConnectError:
|
| 96 |
raise HTTPException(
|
|
@@ -152,14 +152,14 @@ async def ollama_stream(prompt: str, model: str):
|
|
| 152 |
yield error_line + "\n"
|
| 153 |
|
| 154 |
|
| 155 |
-
def stream_summary(
|
| 156 |
text: str,
|
| 157 |
title: Optional[str] = None,
|
| 158 |
model: Optional[str] = None,
|
| 159 |
) -> StreamingResponse:
|
| 160 |
"""Universal funnel: text -> prompt -> Ollama stream -> NDJSON response."""
|
| 161 |
-
ensure_ollama_reachable()
|
| 162 |
-
resolved = resolve_model(model)
|
| 163 |
prompt = build_prompt(title, text)
|
| 164 |
return StreamingResponse(
|
| 165 |
ollama_stream(prompt, resolved),
|
|
|
|
| 47 |
# Cache for installed models to avoid repeated network calls
|
| 48 |
_installed_models_cache: Optional[list] = None
|
| 49 |
|
| 50 |
+
async def resolve_model(model: Optional[str]) -> str:
|
| 51 |
requested = model or ""
|
| 52 |
|
| 53 |
# Prefer what Ollama actually has installed, using cache to avoid repeated network calls.
|
| 54 |
global _installed_models_cache
|
| 55 |
if _installed_models_cache is None:
|
| 56 |
try:
|
| 57 |
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
| 58 |
+
r = await client.get(f"{OLLAMA_BASE_URL}/api/tags")
|
| 59 |
r.raise_for_status()
|
| 60 |
payload = r.json() if r.content else {}
|
| 61 |
_installed_models_cache = [m.get("name") for m in payload.get("models", []) if m.get("name")]
|
|
|
|
| 87 |
return requested
|
| 88 |
|
| 89 |
|
| 90 |
+
async def ensure_ollama_reachable() -> None:
|
| 91 |
try:
|
| 92 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 93 |
+
response = await client.get(f"{OLLAMA_BASE_URL}/api/tags")
|
| 94 |
response.raise_for_status()
|
| 95 |
except httpx.ConnectError:
|
| 96 |
raise HTTPException(
|
|
|
|
| 152 |
yield error_line + "\n"
|
| 153 |
|
| 154 |
|
| 155 |
+
async def stream_summary(
|
| 156 |
text: str,
|
| 157 |
title: Optional[str] = None,
|
| 158 |
model: Optional[str] = None,
|
| 159 |
) -> StreamingResponse:
|
| 160 |
"""Universal funnel: text -> prompt -> Ollama stream -> NDJSON response."""
|
| 161 |
+
await ensure_ollama_reachable()
|
| 162 |
+
resolved = await resolve_model(model)
|
| 163 |
prompt = build_prompt(title, text)
|
| 164 |
return StreamingResponse(
|
| 165 |
ollama_stream(prompt, resolved),
|
frontend/src/App.jsx
CHANGED
|
@@ -16,7 +16,13 @@ function App() {
|
|
| 16 |
const fileInputRef = useRef(null)
|
| 17 |
const warmupAbortRef = useRef(null)
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
useEffect(() => {
|
| 22 |
let cancelled = false
|
|
@@ -92,7 +98,7 @@ function App() {
|
|
| 92 |
}, [selectedModel])
|
| 93 |
|
| 94 |
const handleSubmit = () =>
|
| 95 |
-
submit(activeTab, {
|
| 96 |
youtubeUrl,
|
| 97 |
transcript,
|
| 98 |
selectedFile,
|
|
@@ -114,11 +120,9 @@ function App() {
|
|
| 114 |
}
|
| 115 |
|
| 116 |
const ctrlEnter = (e) => {
|
| 117 |
-
if (e.key === 'Enter' && e.ctrlKey && !loading) handleSubmit()
|
| 118 |
}
|
| 119 |
|
| 120 |
-
const resultProps = { error, loading, response, streamingText, selectedModel }
|
| 121 |
-
|
| 122 |
return (
|
| 123 |
<>
|
| 124 |
<header className="header">
|
|
@@ -131,7 +135,7 @@ function App() {
|
|
| 131 |
className={`model-select${modelReady === true ? ' model-select--ready' : modelReady === false ? ' model-select--warming' : ''}`}
|
| 132 |
value={selectedModel}
|
| 133 |
onChange={(e) => setSelectedModel(e.target.value)}
|
| 134 |
-
disabled={loading || models.length === 0}
|
| 135 |
>
|
| 136 |
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
| 137 |
</select>
|
|
@@ -184,13 +188,15 @@ function App() {
|
|
| 184 |
/>
|
| 185 |
<p className="form-hint">Paste a YouTube URL. Ctrl+Enter to generate.</p>
|
| 186 |
</div>
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
| 194 |
</div>
|
| 195 |
|
| 196 |
{/* Transcript */}
|
|
@@ -212,13 +218,15 @@ function App() {
|
|
| 212 |
{' '}to generate.
|
| 213 |
</p>
|
| 214 |
</div>
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
| 222 |
</div>
|
| 223 |
|
| 224 |
{/* File upload */}
|
|
@@ -256,26 +264,28 @@ function App() {
|
|
| 256 |
</div>
|
| 257 |
)}
|
| 258 |
</div>
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
| 266 |
</div>
|
| 267 |
|
| 268 |
<div className="submit-section">
|
| 269 |
-
{loading && (
|
| 270 |
-
<button className="btn btn-cancel" onClick={cancel}>
|
| 271 |
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
| 272 |
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
| 273 |
</svg>
|
| 274 |
Cancel
|
| 275 |
</button>
|
| 276 |
)}
|
| 277 |
-
<button className="btn btn-primary btn-lg" onClick={handleSubmit} disabled={loading}>
|
| 278 |
-
{loading ? (
|
| 279 |
<><span className="loading-spinner" style={{ width: 16, height: 16 }} /> Processing...</>
|
| 280 |
) : (
|
| 281 |
<>
|
|
|
|
| 16 |
const fileInputRef = useRef(null)
|
| 17 |
const warmupAbortRef = useRef(null)
|
| 18 |
|
| 19 |
+
// One streaming instance per tab — state is fully independent and persists across tab switches
|
| 20 |
+
const ytStreaming = useStreaming()
|
| 21 |
+
const textStreaming = useStreaming()
|
| 22 |
+
const fileStreaming = useStreaming()
|
| 23 |
+
|
| 24 |
+
const streaming = { youtube: ytStreaming, transcript: textStreaming, file: fileStreaming }
|
| 25 |
+
const active = streaming[activeTab]
|
| 26 |
|
| 27 |
useEffect(() => {
|
| 28 |
let cancelled = false
|
|
|
|
| 98 |
}, [selectedModel])
|
| 99 |
|
| 100 |
const handleSubmit = () =>
|
| 101 |
+
active.submit(activeTab, {
|
| 102 |
youtubeUrl,
|
| 103 |
transcript,
|
| 104 |
selectedFile,
|
|
|
|
| 120 |
}
|
| 121 |
|
| 122 |
const ctrlEnter = (e) => {
|
| 123 |
+
if (e.key === 'Enter' && e.ctrlKey && !active.loading) handleSubmit()
|
| 124 |
}
|
| 125 |
|
|
|
|
|
|
|
| 126 |
return (
|
| 127 |
<>
|
| 128 |
<header className="header">
|
|
|
|
| 135 |
className={`model-select${modelReady === true ? ' model-select--ready' : modelReady === false ? ' model-select--warming' : ''}`}
|
| 136 |
value={selectedModel}
|
| 137 |
onChange={(e) => setSelectedModel(e.target.value)}
|
| 138 |
+
disabled={active.loading || models.length === 0}
|
| 139 |
>
|
| 140 |
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
| 141 |
</select>
|
|
|
|
| 188 |
/>
|
| 189 |
<p className="form-hint">Paste a YouTube URL. Ctrl+Enter to generate.</p>
|
| 190 |
</div>
|
| 191 |
+
<InlineResult
|
| 192 |
+
error={ytStreaming.error}
|
| 193 |
+
loading={ytStreaming.loading}
|
| 194 |
+
response={ytStreaming.response}
|
| 195 |
+
streamingText={ytStreaming.streamingText}
|
| 196 |
+
selectedModel={selectedModel}
|
| 197 |
+
loadingLabel={ytStreaming.streamingText ? 'Generating…' : 'Fetching transcript…'}
|
| 198 |
+
placeholderText="Fetching transcript…"
|
| 199 |
+
/>
|
| 200 |
</div>
|
| 201 |
|
| 202 |
{/* Transcript */}
|
|
|
|
| 218 |
{' '}to generate.
|
| 219 |
</p>
|
| 220 |
</div>
|
| 221 |
+
<InlineResult
|
| 222 |
+
error={textStreaming.error}
|
| 223 |
+
loading={textStreaming.loading}
|
| 224 |
+
response={textStreaming.response}
|
| 225 |
+
streamingText={textStreaming.streamingText}
|
| 226 |
+
selectedModel={selectedModel}
|
| 227 |
+
loadingLabel="Generating…"
|
| 228 |
+
placeholderText="Waiting for model…"
|
| 229 |
+
/>
|
| 230 |
</div>
|
| 231 |
|
| 232 |
{/* File upload */}
|
|
|
|
| 264 |
</div>
|
| 265 |
)}
|
| 266 |
</div>
|
| 267 |
+
<InlineResult
|
| 268 |
+
error={fileStreaming.error}
|
| 269 |
+
loading={fileStreaming.loading}
|
| 270 |
+
response={fileStreaming.response}
|
| 271 |
+
streamingText={fileStreaming.streamingText}
|
| 272 |
+
selectedModel={selectedModel}
|
| 273 |
+
loadingLabel="Reading file…"
|
| 274 |
+
placeholderText="Reading file…"
|
| 275 |
+
/>
|
| 276 |
</div>
|
| 277 |
|
| 278 |
<div className="submit-section">
|
| 279 |
+
{active.loading && (
|
| 280 |
+
<button className="btn btn-cancel" onClick={active.cancel}>
|
| 281 |
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
| 282 |
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
| 283 |
</svg>
|
| 284 |
Cancel
|
| 285 |
</button>
|
| 286 |
)}
|
| 287 |
+
<button className="btn btn-primary btn-lg" onClick={handleSubmit} disabled={active.loading}>
|
| 288 |
+
{active.loading ? (
|
| 289 |
<><span className="loading-spinner" style={{ width: 16, height: 16 }} /> Processing...</>
|
| 290 |
) : (
|
| 291 |
<>
|
frontend/src/hooks/useStreaming.js
CHANGED
|
@@ -7,6 +7,10 @@ export function useStreaming() {
|
|
| 7 |
const [error, setError] = useState(null)
|
| 8 |
const [streamingText, setStreamingText] = useState('')
|
| 9 |
const abortRef = useRef(null)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
const readNDJSONStream = async (res) => {
|
| 12 |
const reader = res.body.getReader()
|
|
@@ -33,6 +37,7 @@ export function useStreaming() {
|
|
| 33 |
}
|
| 34 |
if (chunk.response) {
|
| 35 |
accumulated += chunk.response
|
|
|
|
| 36 |
setStreamingText(accumulated)
|
| 37 |
}
|
| 38 |
} catch { /* skip malformed */ }
|
|
@@ -80,10 +85,16 @@ export function useStreaming() {
|
|
| 80 |
}
|
| 81 |
|
| 82 |
const submit = async (activeTab, { youtubeUrl, transcript, selectedFile, selectedModel }) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
setLoading(true)
|
| 84 |
setError(null)
|
| 85 |
setResponse(null)
|
| 86 |
setStreamingText('')
|
|
|
|
| 87 |
|
| 88 |
try {
|
| 89 |
let summary
|
|
@@ -101,15 +112,26 @@ export function useStreaming() {
|
|
| 101 |
summary = await streamFrom(`/summarize/file?model=${encodeURIComponent(selectedModel)}`, { formData: fd })
|
| 102 |
}
|
| 103 |
|
|
|
|
| 104 |
setResponse({ summary, success: true, source_type: activeTab, model: selectedModel })
|
| 105 |
} catch (err) {
|
|
|
|
| 106 |
if (err.name === 'AbortError') {
|
| 107 |
-
// User cancelled —
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
return
|
| 109 |
}
|
| 110 |
setError(err.message || 'An error occurred')
|
| 111 |
} finally {
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
}
|
| 114 |
}
|
| 115 |
|
|
|
|
| 7 |
const [error, setError] = useState(null)
|
| 8 |
const [streamingText, setStreamingText] = useState('')
|
| 9 |
const abortRef = useRef(null)
|
| 10 |
+
// Tracks accumulated text in real-time so cancel() can save it as a response
|
| 11 |
+
const accumulatedRef = useRef('')
|
| 12 |
+
// Incremented on each submit so stale completions don't overwrite fresh state
|
| 13 |
+
const submitIdRef = useRef(0)
|
| 14 |
|
| 15 |
const readNDJSONStream = async (res) => {
|
| 16 |
const reader = res.body.getReader()
|
|
|
|
| 37 |
}
|
| 38 |
if (chunk.response) {
|
| 39 |
accumulated += chunk.response
|
| 40 |
+
accumulatedRef.current = accumulated
|
| 41 |
setStreamingText(accumulated)
|
| 42 |
}
|
| 43 |
} catch { /* skip malformed */ }
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
const submit = async (activeTab, { youtubeUrl, transcript, selectedFile, selectedModel }) => {
|
| 88 |
+
// Abort any in-flight request before starting a new one
|
| 89 |
+
abortRef.current?.abort()
|
| 90 |
+
|
| 91 |
+
const myId = ++submitIdRef.current
|
| 92 |
+
|
| 93 |
setLoading(true)
|
| 94 |
setError(null)
|
| 95 |
setResponse(null)
|
| 96 |
setStreamingText('')
|
| 97 |
+
accumulatedRef.current = ''
|
| 98 |
|
| 99 |
try {
|
| 100 |
let summary
|
|
|
|
| 112 |
summary = await streamFrom(`/summarize/file?model=${encodeURIComponent(selectedModel)}`, { formData: fd })
|
| 113 |
}
|
| 114 |
|
| 115 |
+
if (submitIdRef.current !== myId) return // superseded by a newer submit
|
| 116 |
setResponse({ summary, success: true, source_type: activeTab, model: selectedModel })
|
| 117 |
} catch (err) {
|
| 118 |
+
if (submitIdRef.current !== myId) return // superseded — don't touch state
|
| 119 |
if (err.name === 'AbortError') {
|
| 120 |
+
// User cancelled — keep whatever was generated so far as the result
|
| 121 |
+
const partial = accumulatedRef.current.trim()
|
| 122 |
+
if (partial) {
|
| 123 |
+
setResponse({ summary: partial, success: true, source_type: activeTab, model: selectedModel, cancelled: true })
|
| 124 |
+
}
|
| 125 |
+
// If nothing was generated yet, just reset silently (no error shown)
|
| 126 |
return
|
| 127 |
}
|
| 128 |
setError(err.message || 'An error occurred')
|
| 129 |
} finally {
|
| 130 |
+
// Only the current submit should clear loading — stale ones must not interfere
|
| 131 |
+
if (submitIdRef.current === myId) {
|
| 132 |
+
setLoading(false)
|
| 133 |
+
setStreamingText('')
|
| 134 |
+
}
|
| 135 |
}
|
| 136 |
}
|
| 137 |
|