compendious commited on
Commit
932022e
·
1 Parent(s): 6df9ff0

Stronger alignment and word flow. Backend finalized.

Browse files
Files changed (3) hide show
  1. backend/app.py +79 -30
  2. frontend/src/App.css +30 -0
  3. frontend/src/App.jsx +58 -21
backend/app.py CHANGED
@@ -6,7 +6,7 @@ from typing import Optional
6
  import httpx
7
  from fastapi import FastAPI, HTTPException, UploadFile, File
8
  from fastapi.middleware.cors import CORSMiddleware
9
- from fastapi.responses import HTMLResponse
10
  from pydantic import BaseModel
11
 
12
  # ---------------------------------------------------------------------------
@@ -69,53 +69,84 @@ class SummarizeResponse(BaseModel):
69
  # ---------------------------------------------------------------------------
70
 
71
  def _build_prompt(title: Optional[str], text: str) -> str:
72
- header = f"Title: {title}\n" if title else ""
 
 
 
 
 
 
 
 
 
 
 
73
  return (
74
- "Summarise the following article in 2–4 clear, factual sentences. "
75
- "Do not add opinions or commentary.\n\n"
76
- f"{header}"
77
  f"Article:\n{text}\n\n"
78
  "Summary:"
79
  )
80
 
81
 
82
- async def call_ollama(prompt: str, max_tokens: int = MAX_SUMMARY_TOKENS) -> str:
83
- """Send a prompt to the local Ollama completions endpoint and return the text."""
84
- payload = {
85
  "model": MODEL_NAME,
86
  "prompt": prompt,
87
- "max_tokens": max_tokens,
88
- "temperature": TEMPERATURE,
89
- "stop": ["\n\n", "Article:", "Title:"], # prevent runaway generation
 
 
 
90
  }
91
 
 
 
 
 
 
 
 
 
 
 
92
  async with httpx.AsyncClient(timeout=120.0) as client:
93
  try:
94
- resp = await client.post(OLLAMA_COMPLETIONS_URL, json=payload)
 
 
 
95
  resp.raise_for_status()
96
  except httpx.ConnectError:
97
- raise HTTPException(
98
- status_code=503,
99
- detail=(
100
- "Cannot reach Ollama at 127.0.0.1:11434. "
101
- "Make sure `ollama serve` is running."
102
- ),
103
- )
104
  except httpx.HTTPStatusError as exc:
105
- raise HTTPException(
106
- status_code=502,
107
- detail=f"Ollama returned an error: {exc.response.text}",
108
- )
109
 
110
  data = resp.json()
111
  try:
112
- return data["choices"][0]["text"].strip()
113
- except (KeyError, IndexError) as exc:
114
  logger.error("Unexpected Ollama response: %s", data)
115
- raise HTTPException(
116
- status_code=502,
117
- detail=f"Unexpected response shape from Ollama: {exc}",
118
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
 
121
  # ---------------------------------------------------------------------------
@@ -183,7 +214,7 @@ async def status():
183
 
184
  @app.post("/summarize/transcript", response_model=SummarizeResponse)
185
  async def summarize_transcript(request: TranscriptRequest):
186
- """Summarise a provided article or transcript."""
187
  if not request.text.strip():
188
  raise HTTPException(status_code=400, detail="text must not be empty")
189
 
@@ -193,6 +224,24 @@ async def summarize_transcript(request: TranscriptRequest):
193
  return SummarizeResponse(summary=summary, success=True, source_type="transcript")
194
 
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  @app.post("/summarize/file", response_model=SummarizeResponse)
197
  async def summarize_file(file: UploadFile = File(...)):
198
  """Summarise content from an uploaded .txt file."""
 
6
  import httpx
7
  from fastapi import FastAPI, HTTPException, UploadFile, File
8
  from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import HTMLResponse, StreamingResponse
10
  from pydantic import BaseModel
11
 
12
  # ---------------------------------------------------------------------------
 
69
  # ---------------------------------------------------------------------------
70
 
71
  def _build_prompt(title: Optional[str], text: str) -> str:
72
+ if title:
73
+ instructions = (
74
+ f'The article is titled "{title}". '
75
+ "If the title is a question, answer it directly in one sentence using only facts from the article. "
76
+ "If the title is not a question, write one sentence that gives a concise, high-level overview "
77
+ "of the article, briefly enumerating all key facts."
78
+ )
79
+ else:
80
+ instructions = (
81
+ "Write one sentence that gives a concise, high-level overview of the article, "
82
+ "briefly enumerating all key facts."
83
+ )
84
  return (
85
+ f"{instructions}\n"
86
+ "Do not add opinions, commentary, or filler phrases like 'The article discusses'.\n"
87
+ "Output the summary sentence only — nothing else.\n\n"
88
  f"Article:\n{text}\n\n"
89
  "Summary:"
90
  )
91
 
92
 
93
+ def _ollama_payload(prompt: str, stream: bool = False) -> dict:
94
+ return {
 
95
  "model": MODEL_NAME,
96
  "prompt": prompt,
97
+ "stream": stream,
98
+ "options": {
99
+ "num_predict": MAX_SUMMARY_TOKENS,
100
+ "temperature": TEMPERATURE,
101
+ "stop": ["\n\n", "Article:", "Title:"],
102
+ },
103
  }
104
 
105
+
106
+ def _ollama_connect_error() -> HTTPException:
107
+ return HTTPException(
108
+ status_code=503,
109
+ detail="Cannot reach Ollama at 127.0.0.1:11434. Make sure `ollama serve` is running.",
110
+ )
111
+
112
+
113
+ async def call_ollama(prompt: str) -> str:
114
+ """Non-streaming call — returns the full generated text."""
115
  async with httpx.AsyncClient(timeout=120.0) as client:
116
  try:
117
+ resp = await client.post(
118
+ f"{OLLAMA_BASE_URL}/api/generate",
119
+ json=_ollama_payload(prompt, stream=False),
120
+ )
121
  resp.raise_for_status()
122
  except httpx.ConnectError:
123
+ raise _ollama_connect_error()
 
 
 
 
 
 
124
  except httpx.HTTPStatusError as exc:
125
+ raise HTTPException(status_code=502, detail=f"Ollama error: {exc.response.text}")
 
 
 
126
 
127
  data = resp.json()
128
  try:
129
+ return data["response"].strip()
130
+ except KeyError as exc:
131
  logger.error("Unexpected Ollama response: %s", data)
132
+ raise HTTPException(status_code=502, detail=f"Unexpected response shape: {exc}")
133
+
134
+
135
+ async def stream_ollama(prompt: str):
136
+ """Async generator that yields raw NDJSON lines from Ollama's streaming endpoint."""
137
+ async with httpx.AsyncClient(timeout=120.0) as client:
138
+ try:
139
+ async with client.stream(
140
+ "POST",
141
+ f"{OLLAMA_BASE_URL}/api/generate",
142
+ json=_ollama_payload(prompt, stream=True),
143
+ ) as resp:
144
+ resp.raise_for_status()
145
+ async for line in resp.aiter_lines():
146
+ if line:
147
+ yield line + "\n"
148
+ except httpx.ConnectError:
149
+ raise _ollama_connect_error()
150
 
151
 
152
  # ---------------------------------------------------------------------------
 
214
 
215
  @app.post("/summarize/transcript", response_model=SummarizeResponse)
216
  async def summarize_transcript(request: TranscriptRequest):
217
+ """Summarise a provided article or transcript (non-streaming)."""
218
  if not request.text.strip():
219
  raise HTTPException(status_code=400, detail="text must not be empty")
220
 
 
224
  return SummarizeResponse(summary=summary, success=True, source_type="transcript")
225
 
226
 
227
+ @app.post("/summarize/transcript/stream")
228
+ async def summarize_transcript_stream(request: TranscriptRequest):
229
+ """
230
+ Streaming variant — pipes Ollama's NDJSON stream directly to the client.
231
+ Each line is a JSON object: {"response": "<token>", "done": false}.
232
+ """
233
+ if not request.text.strip():
234
+ raise HTTPException(status_code=400, detail="text must not be empty")
235
+
236
+ prompt = _build_prompt(request.title, request.text)
237
+
238
+ return StreamingResponse(
239
+ stream_ollama(prompt),
240
+ media_type="application/x-ndjson",
241
+ headers={"X-Accel-Buffering": "no"}, # disable nginx buffering if behind a proxy
242
+ )
243
+
244
+
245
  @app.post("/summarize/file", response_model=SummarizeResponse)
246
  async def summarize_file(file: UploadFile = File(...)):
247
  """Summarise content from an uploaded .txt file."""
frontend/src/App.css CHANGED
@@ -299,6 +299,18 @@
299
  align-items: center;
300
  }
301
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  .inline-result__label {
303
  display: flex;
304
  align-items: center;
@@ -315,4 +327,22 @@
315
  color: var(--color-fg-default);
316
  white-space: pre-wrap;
317
  margin: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  }
 
299
  align-items: center;
300
  }
301
 
302
+ .inline-result--streaming {
303
+ background-color: var(--color-canvas-inset);
304
+ border-color: var(--color-accent-emphasis);
305
+ color: var(--color-fg-default);
306
+ flex-direction: column;
307
+ gap: var(--spacing-2);
308
+ }
309
+
310
+ .inline-result--streaming .inline-result__label {
311
+ color: var(--color-accent-fg);
312
+ }
313
+
314
  .inline-result__label {
315
  display: flex;
316
  align-items: center;
 
327
  color: var(--color-fg-default);
328
  white-space: pre-wrap;
329
  margin: 0;
330
+ }
331
+
332
+ /* Streaming cursor blink */
333
+ .streaming-cursor {
334
+ animation: blink 0.7s step-end infinite;
335
+ color: var(--color-accent-fg);
336
+ font-weight: 300;
337
+ }
338
+
339
+ @keyframes blink {
340
+ 50% {
341
+ opacity: 0;
342
+ }
343
+ }
344
+
345
+ .streaming-placeholder {
346
+ color: var(--color-fg-muted);
347
+ font-style: italic;
348
  }
frontend/src/App.jsx CHANGED
@@ -2,8 +2,6 @@ import { useState, useRef } from 'react'
2
  import './App.css'
3
 
4
  const API_BASE = 'http://localhost:8000'
5
- const OLLAMA_URL = 'http://127.0.0.1:11434/v1/completions'
6
- const MODEL_NAME = 'phi4-mini:3.8b'
7
 
8
  function App() {
9
  const [activeTab, setActiveTab] = useState('youtube')
@@ -13,30 +11,58 @@ function App() {
13
  const [loading, setLoading] = useState(false)
14
  const [response, setResponse] = useState(null)
15
  const [error, setError] = useState(null)
 
16
  const fileInputRef = useRef(null)
 
17
 
18
- const callOllama = async (text) => {
19
- const prompt = `Summarise the following article in 2–4 clear, factual sentences. Do not add opinions or commentary.\n\nArticle:\n${text}\n\nSummary:`
 
 
 
 
20
 
21
- const res = await fetch(OLLAMA_URL, {
22
  method: 'POST',
23
  headers: { 'Content-Type': 'application/json' },
24
- body: JSON.stringify({
25
- model: MODEL_NAME,
26
- prompt,
27
- max_tokens: 120,
28
- temperature: 0.2,
29
- stop: ['\n\n', 'Article:', 'Title:']
30
- })
31
  })
32
 
33
  if (!res.ok) {
34
  const body = await res.text()
35
- throw new Error(`Ollama error (${res.status}): ${body}`)
36
  }
37
 
38
- const data = await res.json()
39
- return data.choices[0].text.trim()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  }
41
 
42
  const handleSubmit = async () => {
@@ -61,8 +87,9 @@ function App() {
61
  if (!transcript.trim()) {
62
  throw new Error('Please enter some text')
63
  }
64
- const summary = await callOllama(transcript)
65
- result = { summary, success: true, source_type: 'transcript', model: MODEL_NAME }
 
66
  } else if (activeTab === 'file') {
67
  if (!selectedFile) {
68
  throw new Error('Please select a file')
@@ -183,8 +210,11 @@ function App() {
183
  placeholder="Paste your article or transcript here..."
184
  value={transcript}
185
  onChange={(e) => setTranscript(e.target.value)}
 
 
 
186
  />
187
- <p className="form-hint">Paste any text content you want to summarize.</p>
188
  </div>
189
 
190
  {/* Inline result — only shown when this tab triggered it */}
@@ -195,9 +225,16 @@ function App() {
195
  </div>
196
  )}
197
  {activeTab === 'transcript' && loading && (
198
- <div className="inline-result inline-result--loading fade-in">
199
- <span className="loading-spinner" style={{ width: 14, height: 14 }} />
200
- Generating summary…
 
 
 
 
 
 
 
201
  </div>
202
  )}
203
  {activeTab === 'transcript' && response && !loading && (
 
2
  import './App.css'
3
 
4
  const API_BASE = 'http://localhost:8000'
 
 
5
 
6
  function App() {
7
  const [activeTab, setActiveTab] = useState('youtube')
 
11
  const [loading, setLoading] = useState(false)
12
  const [response, setResponse] = useState(null)
13
  const [error, setError] = useState(null)
14
+ const [streamingText, setStreamingText] = useState('')
15
  const fileInputRef = useRef(null)
16
+ const abortRef = useRef(null)
17
 
18
+ /**
19
+ * Stream tokens from the backend's /summarize/transcript/stream endpoint.
20
+ * The backend owns the prompt — we just send the raw text and title.
21
+ */
22
+ const streamFromBackend = async (text, title) => {
23
+ abortRef.current = new AbortController()
24
 
25
+ const res = await fetch(`${API_BASE}/summarize/transcript/stream`, {
26
  method: 'POST',
27
  headers: { 'Content-Type': 'application/json' },
28
+ signal: abortRef.current.signal,
29
+ body: JSON.stringify({ text, title: title || null }),
 
 
 
 
 
30
  })
31
 
32
  if (!res.ok) {
33
  const body = await res.text()
34
+ throw new Error(`Backend error (${res.status}): ${body}`)
35
  }
36
 
37
+ const reader = res.body.getReader()
38
+ const decoder = new TextDecoder()
39
+ let accumulated = ''
40
+ let buffer = ''
41
+
42
+ while (true) {
43
+ const { done, value } = await reader.read()
44
+ if (done) break
45
+
46
+ buffer += decoder.decode(value, { stream: true })
47
+
48
+ const lines = buffer.split('\n')
49
+ buffer = lines.pop() // keep incomplete line in buffer
50
+
51
+ for (const line of lines) {
52
+ if (!line.trim()) continue
53
+ try {
54
+ const chunk = JSON.parse(line)
55
+ if (chunk.response) {
56
+ accumulated += chunk.response
57
+ setStreamingText(accumulated)
58
+ }
59
+ } catch {
60
+ // skip malformed lines
61
+ }
62
+ }
63
+ }
64
+
65
+ return accumulated.trim()
66
  }
67
 
68
  const handleSubmit = async () => {
 
87
  if (!transcript.trim()) {
88
  throw new Error('Please enter some text')
89
  }
90
+ setStreamingText('')
91
+ const summary = await streamFromBackend(transcript, null)
92
+ result = { summary, success: true, source_type: 'transcript', model: 'phi4-mini:3.8b' }
93
  } else if (activeTab === 'file') {
94
  if (!selectedFile) {
95
  throw new Error('Please select a file')
 
210
  placeholder="Paste your article or transcript here..."
211
  value={transcript}
212
  onChange={(e) => setTranscript(e.target.value)}
213
+ onKeyDown={(e) => {
214
+ if (e.key === 'Enter' && e.ctrlKey && !loading) handleSubmit()
215
+ }}
216
  />
217
+ <p className="form-hint">Paste any text content you want to summarize. Press <kbd style={{ fontFamily: 'inherit', background: 'var(--color-canvas-inset)', border: '1px solid var(--color-border-muted)', borderRadius: 3, padding: '0 4px', fontSize: 11 }}>Ctrl</kbd> + <kbd style={{ fontFamily: 'inherit', background: 'var(--color-canvas-inset)', border: '1px solid var(--color-border-muted)', borderRadius: 3, padding: '0 4px', fontSize: 11 }}>Enter</kbd> to generate.</p>
218
  </div>
219
 
220
  {/* Inline result — only shown when this tab triggered it */}
 
225
  </div>
226
  )}
227
  {activeTab === 'transcript' && loading && (
228
+ <div className="inline-result inline-result--streaming fade-in">
229
+ <div className="inline-result__label">
230
+ <span className="loading-spinner" style={{ width: 12, height: 12 }} />
231
+ Generating…
232
+ <span className="response-badge" style={{ marginLeft: 'auto' }}>phi4-mini:3.8b</span>
233
+ </div>
234
+ <p className="inline-result__text">
235
+ {streamingText || <span className="streaming-placeholder">Waiting for model…</span>}
236
+ <span className="streaming-cursor">▌</span>
237
+ </p>
238
  </div>
239
  )}
240
  {activeTab === 'transcript' && response && !loading && (