compendious commited on
Commit
054a1b5
·
1 Parent(s): 851f234

prompt engineering improvements

Browse files
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.Client(timeout=5.0) as client:
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.Client(timeout=10.0) as client:
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
- const { loading, response, error, streamingText, submit, cancel } = useStreaming()
 
 
 
 
 
 
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
- {activeTab === 'youtube' && (
188
- <InlineResult
189
- {...resultProps}
190
- loadingLabel={streamingText ? 'Generating…' : 'Fetching transcript…'}
191
- placeholderText="Fetching transcript…"
192
- />
193
- )}
 
 
194
  </div>
195
 
196
  {/* Transcript */}
@@ -212,13 +218,15 @@ function App() {
212
  {' '}to generate.
213
  </p>
214
  </div>
215
- {activeTab === 'transcript' && (
216
- <InlineResult
217
- {...resultProps}
218
- loadingLabel="Generating…"
219
- placeholderText="Waiting for model…"
220
- />
221
- )}
 
 
222
  </div>
223
 
224
  {/* File upload */}
@@ -256,26 +264,28 @@ function App() {
256
  </div>
257
  )}
258
  </div>
259
- {activeTab === 'file' && (
260
- <InlineResult
261
- {...resultProps}
262
- loadingLabel="Reading file…"
263
- placeholderText="Reading file…"
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 — clear loading silently, keep any partial text
 
 
 
 
 
108
  return
109
  }
110
  setError(err.message || 'An error occurred')
111
  } finally {
112
- setLoading(false)
 
 
 
 
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