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

Output rendering, documentation, model readiness UI

Browse files
backend/app.py CHANGED
@@ -1,5 +1,7 @@
1
  from typing import Optional
2
 
 
 
3
  import httpx
4
  from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Request
5
  from fastapi.middleware.cors import CORSMiddleware
@@ -32,7 +34,6 @@ app.add_middleware(
32
  )
33
 
34
  # Only mount frontend in production when dist/ exists
35
- import os
36
  if os.path.isdir("frontend/dist"):
37
  app.mount("/", StaticFiles(directory="frontend/dist", html=True), name="static")
38
 
@@ -101,6 +102,53 @@ async def list_models():
101
  return {"default": DEFAULT_MODEL, "available": AVAILABLE_MODELS}
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  @app.post("/summarize/transcript")
105
  async def summarize_transcript(
106
  request: TranscriptRequest,
 
1
  from typing import Optional
2
 
3
+ import os
4
+
5
  import httpx
6
  from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Request
7
  from fastapi.middleware.cors import CORSMiddleware
 
34
  )
35
 
36
  # Only mount frontend in production when dist/ exists
 
37
  if os.path.isdir("frontend/dist"):
38
  app.mount("/", StaticFiles(directory="frontend/dist", html=True), name="static")
39
 
 
102
  return {"default": DEFAULT_MODEL, "available": AVAILABLE_MODELS}
103
 
104
 
105
+ @app.post("/warmup")
106
+ async def warmup_model(model: Optional[str] = None):
107
+ """Load a model into Ollama VRAM.
108
+
109
+ Per the Ollama API docs: sending a generate request with an empty
110
+ prompt (no 'prompt' key at all) causes Ollama to load the model into
111
+ memory and return once it is ready. stream=False means this endpoint
112
+ only returns *after* the model is fully loaded — the frontend can await
113
+ it to know exactly when the model is warm.
114
+ """
115
+ keep_alive = os.getenv("OLLAMA_KEEP_ALIVE", "30m")
116
+ target = model or DEFAULT_MODEL
117
+ try:
118
+ async with httpx.AsyncClient(timeout=120.0) as client:
119
+ await client.post(
120
+ f"{OLLAMA_BASE_URL}/api/generate",
121
+ json={"model": target, "keep_alive": keep_alive, "stream": False},
122
+ )
123
+ except Exception:
124
+ pass # Non-fatal
125
+ return {"model": target, "status": "warmed"}
126
+
127
+
128
+ @app.get("/warmup/status")
129
+ async def warmup_status(model: Optional[str] = None):
130
+ """Check whether a model is currently loaded in Ollama's memory.
131
+
132
+ Hits GET /api/ps (the equivalent of `ollama ps`) and returns
133
+ {"loaded": true/false, "model": name}.
134
+ """
135
+ target = model or DEFAULT_MODEL
136
+ try:
137
+ async with httpx.AsyncClient(timeout=5.0) as client:
138
+ r = await client.get(f"{OLLAMA_BASE_URL}/api/ps")
139
+ r.raise_for_status()
140
+ payload = r.json() if r.content else {}
141
+ running = [m.get("name", "") for m in payload.get("models", [])]
142
+ # Match on exact name or prefix (e.g. "phi4-mini" matches "phi4-mini:latest")
143
+ loaded = any(
144
+ name == target or name.startswith(target + ":")
145
+ for name in running
146
+ )
147
+ return {"model": target, "loaded": loaded}
148
+ except Exception:
149
+ return {"model": target, "loaded": False}
150
+
151
+
152
  @app.post("/summarize/transcript")
153
  async def summarize_transcript(
154
  request: TranscriptRequest,
frontend/src/App.css CHANGED
@@ -70,6 +70,19 @@
70
  cursor: not-allowed;
71
  }
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  /* Main content */
74
  .main {
75
  flex: 1;
@@ -220,12 +233,35 @@
220
  /* Submit section */
221
  .submit-section {
222
  display: flex;
 
223
  justify-content: flex-end;
 
224
  padding-top: var(--spacing-4);
225
  border-top: 1px solid var(--color-border-muted);
226
  margin-top: var(--spacing-4);
227
  }
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  /* Response section */
230
  .response-section {
231
  margin-top: var(--spacing-5);
@@ -374,29 +410,129 @@
374
  width: 100%;
375
  }
376
 
 
377
  .inline-result__text {
378
  font-size: 14px;
379
  line-height: 1.7;
380
  color: var(--color-fg-default);
381
  margin: 0;
 
382
  }
383
 
384
- .inline-result__text strong {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  font-weight: 600;
 
 
386
  color: var(--color-fg-default);
387
  }
388
-
389
- .inline-result__text em {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  font-style: italic;
391
  }
392
 
 
393
  .inline-result__text code {
394
  font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
395
  font-size: 12px;
396
  background-color: var(--color-canvas-inset);
397
  border: 1px solid var(--color-border-muted);
398
  border-radius: 3px;
399
- padding: 0 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  }
401
 
402
  /* Streaming cursor blink */
 
70
  cursor: not-allowed;
71
  }
72
 
73
+ /* Model warm-up border states */
74
+ .model-select--ready {
75
+ border-color: rgba(46, 160, 67, 0.7) !important;
76
+ box-shadow: 0 0 0 2px rgba(46, 160, 67, 0.15);
77
+ transition: border-color 0.4s, box-shadow 0.4s;
78
+ }
79
+
80
+ .model-select--warming {
81
+ border-color: rgba(234, 179, 8, 0.7) !important;
82
+ box-shadow: 0 0 0 2px rgba(234, 179, 8, 0.12);
83
+ transition: border-color 0.4s, box-shadow 0.4s;
84
+ }
85
+
86
  /* Main content */
87
  .main {
88
  flex: 1;
 
233
  /* Submit section */
234
  .submit-section {
235
  display: flex;
236
+ align-items: center;
237
  justify-content: flex-end;
238
+ gap: var(--spacing-2);
239
  padding-top: var(--spacing-4);
240
  border-top: 1px solid var(--color-border-muted);
241
  margin-top: var(--spacing-4);
242
  }
243
 
244
+ /* Cancel / stop button */
245
+ .btn-cancel {
246
+ display: inline-flex;
247
+ align-items: center;
248
+ gap: 6px;
249
+ padding: 8px 16px;
250
+ font-size: 13px;
251
+ font-weight: 500;
252
+ border-radius: 6px;
253
+ border: 1px solid rgba(248, 81, 73, 0.4);
254
+ background-color: rgba(248, 81, 73, 0.08);
255
+ color: var(--color-danger-fg);
256
+ cursor: pointer;
257
+ transition: background-color 0.15s, border-color 0.15s;
258
+ }
259
+
260
+ .btn-cancel:hover {
261
+ background-color: rgba(248, 81, 73, 0.16);
262
+ border-color: rgba(248, 81, 73, 0.6);
263
+ }
264
+
265
  /* Response section */
266
  .response-section {
267
  margin-top: var(--spacing-5);
 
410
  width: 100%;
411
  }
412
 
413
+ /* ── Prose styles for ReactMarkdown output ── */
414
  .inline-result__text {
415
  font-size: 14px;
416
  line-height: 1.7;
417
  color: var(--color-fg-default);
418
  margin: 0;
419
+ width: 100%;
420
  }
421
 
422
+ /* Paragraphs */
423
+ .inline-result__text p {
424
+ margin: 0 0 0.6em;
425
+ }
426
+ .inline-result__text p:last-child {
427
+ margin-bottom: 0;
428
+ }
429
+
430
+ /* Headings */
431
+ .inline-result__text h1,
432
+ .inline-result__text h2,
433
+ .inline-result__text h3,
434
+ .inline-result__text h4,
435
+ .inline-result__text h5,
436
+ .inline-result__text h6 {
437
  font-weight: 600;
438
+ line-height: 1.3;
439
+ margin: 0.9em 0 0.35em;
440
  color: var(--color-fg-default);
441
  }
442
+ .inline-result__text h1 { font-size: 1.35em; }
443
+ .inline-result__text h2 { font-size: 1.2em; }
444
+ .inline-result__text h3 { font-size: 1.08em; }
445
+ .inline-result__text h4,
446
+ .inline-result__text h5,
447
+ .inline-result__text h6 { font-size: 1em; }
448
+ .inline-result__text h1:first-child,
449
+ .inline-result__text h2:first-child,
450
+ .inline-result__text h3:first-child { margin-top: 0; }
451
+
452
+ /* Lists — top-level */
453
+ .inline-result__text ul,
454
+ .inline-result__text ol {
455
+ margin: 0.4em 0 0.6em;
456
+ padding-left: 1.5em;
457
+ }
458
+ .inline-result__text ul { list-style-type: disc; }
459
+ .inline-result__text ol { list-style-type: decimal; }
460
+
461
+ /* Nested lists — level 2 */
462
+ .inline-result__text ul ul,
463
+ .inline-result__text ol ul { list-style-type: circle; }
464
+ .inline-result__text ul ol,
465
+ .inline-result__text ol ol { list-style-type: lower-alpha; }
466
+ .inline-result__text ul ul,
467
+ .inline-result__text ol ul,
468
+ .inline-result__text ul ol,
469
+ .inline-result__text ol ol {
470
+ margin: 0.15em 0;
471
+ padding-left: 1.4em;
472
+ }
473
+
474
+ /* Nested lists — level 3+ */
475
+ .inline-result__text ul ul ul,
476
+ .inline-result__text ol ul ul { list-style-type: square; }
477
+ .inline-result__text ul ul ul,
478
+ .inline-result__text ol ol ol,
479
+ .inline-result__text ul ol ul,
480
+ .inline-result__text ol ul ol {
481
+ margin: 0.1em 0;
482
+ padding-left: 1.3em;
483
+ }
484
+
485
+ .inline-result__text li {
486
+ margin: 0.2em 0;
487
+ }
488
+ .inline-result__text li > p { margin: 0; }
489
+
490
+ /* Inline emphasis */
491
+ .inline-result__text strong { font-weight: 600; }
492
+ .inline-result__text em { font-style: italic; }
493
+
494
+ /* Blockquote */
495
+ .inline-result__text blockquote {
496
+ margin: 0.6em 0;
497
+ padding: 0.3em 0 0.3em 0.9em;
498
+ border-left: 3px solid var(--color-border-default);
499
+ color: var(--color-fg-muted);
500
  font-style: italic;
501
  }
502
 
503
+ /* Inline code */
504
  .inline-result__text code {
505
  font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
506
  font-size: 12px;
507
  background-color: var(--color-canvas-inset);
508
  border: 1px solid var(--color-border-muted);
509
  border-radius: 3px;
510
+ padding: 0.1em 0.35em;
511
+ }
512
+
513
+ /* Fenced code blocks */
514
+ .inline-result__text pre {
515
+ background-color: var(--color-canvas-inset);
516
+ border: 1px solid var(--color-border-muted);
517
+ border-radius: 6px;
518
+ padding: 0.75em 1em;
519
+ margin: 0.6em 0;
520
+ overflow-x: auto;
521
+ font-size: 12px;
522
+ line-height: 1.6;
523
+ }
524
+ .inline-result__text pre code {
525
+ background: none;
526
+ border: none;
527
+ padding: 0;
528
+ font-size: inherit;
529
+ }
530
+
531
+ /* Horizontal rule */
532
+ .inline-result__text hr {
533
+ border: none;
534
+ border-top: 1px solid var(--color-border-muted);
535
+ margin: 0.8em 0;
536
  }
537
 
538
  /* Streaming cursor blink */
frontend/src/App.jsx CHANGED
@@ -12,9 +12,11 @@ function App() {
12
  const [selectedFile, setSelectedFile] = useState(null)
13
  const [models, setModels] = useState([])
14
  const [selectedModel, setSelectedModel] = useState('')
 
15
  const fileInputRef = useRef(null)
 
16
 
17
- const { loading, response, error, streamingText, submit } = useStreaming()
18
 
19
  useEffect(() => {
20
  let cancelled = false
@@ -37,6 +39,58 @@ function App() {
37
  return () => { cancelled = true }
38
  }, [])
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  const handleSubmit = () =>
41
  submit(activeTab, {
42
  youtubeUrl,
@@ -74,13 +128,14 @@ function App() {
74
  </a>
75
  <div className="header-actions">
76
  <select
77
- className="model-select"
78
  value={selectedModel}
79
  onChange={(e) => setSelectedModel(e.target.value)}
80
  disabled={loading || models.length === 0}
81
  >
82
  {models.map((m) => <option key={m} value={m}>{m}</option>)}
83
  </select>
 
84
  <a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer" className="btn" style={{ textDecoration: 'none' }}>
85
  API Docs
86
  </a>
@@ -132,7 +187,7 @@ function App() {
132
  {activeTab === 'youtube' && (
133
  <InlineResult
134
  {...resultProps}
135
- loadingLabel="Fetching transcript…"
136
  placeholderText="Fetching transcript…"
137
  />
138
  )}
@@ -211,6 +266,14 @@ function App() {
211
  </div>
212
 
213
  <div className="submit-section">
 
 
 
 
 
 
 
 
214
  <button className="btn btn-primary btn-lg" onClick={handleSubmit} disabled={loading}>
215
  {loading ? (
216
  <><span className="loading-spinner" style={{ width: 16, height: 16 }} /> Processing...</>
 
12
  const [selectedFile, setSelectedFile] = useState(null)
13
  const [models, setModels] = useState([])
14
  const [selectedModel, setSelectedModel] = useState('')
15
+ const [modelReady, setModelReady] = useState(null) // null=unknown, false=warming, true=ready
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
 
39
  return () => { cancelled = true }
40
  }, [])
41
 
42
+ // Warm up the selected model whenever the selection changes.
43
+ // 1. POST /warmup — tells Ollama to load the model; only resolves when done.
44
+ // 2. While waiting, poll GET /warmup/status every 2 s to update the indicator sooner.
45
+ useEffect(() => {
46
+ if (!selectedModel) return
47
+
48
+ // Cancel any in-flight warmup for the previous model
49
+ if (warmupAbortRef.current) warmupAbortRef.current.abort()
50
+ const controller = new AbortController()
51
+ warmupAbortRef.current = controller
52
+
53
+ setModelReady(false)
54
+
55
+ // Poll /warmup/status every 2 s until loaded or aborted
56
+ let pollTimer = null
57
+ const poll = async () => {
58
+ if (controller.signal.aborted) return
59
+ try {
60
+ const r = await fetch(
61
+ `${API_BASE}/warmup/status?model=${encodeURIComponent(selectedModel)}`,
62
+ { signal: controller.signal },
63
+ )
64
+ if (r.ok) {
65
+ const data = await r.json()
66
+ if (data.loaded) {
67
+ setModelReady(true)
68
+ return
69
+ }
70
+ }
71
+ } catch { /* ignore */ }
72
+ if (!controller.signal.aborted) {
73
+ pollTimer = setTimeout(poll, 2000)
74
+ }
75
+ }
76
+ poll()
77
+
78
+ // POST /warmup — blocks until Ollama has the model in VRAM
79
+ fetch(`${API_BASE}/warmup`, {
80
+ method: 'POST',
81
+ headers: { 'Content-Type': 'application/json' },
82
+ body: JSON.stringify({ model: selectedModel }),
83
+ signal: controller.signal,
84
+ })
85
+ .then(() => { setModelReady(true) })
86
+ .catch(() => { /* warmup errors are non-fatal */ })
87
+
88
+ return () => {
89
+ controller.abort()
90
+ clearTimeout(pollTimer)
91
+ }
92
+ }, [selectedModel])
93
+
94
  const handleSubmit = () =>
95
  submit(activeTab, {
96
  youtubeUrl,
 
128
  </a>
129
  <div className="header-actions">
130
  <select
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>
138
+
139
  <a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer" className="btn" style={{ textDecoration: 'none' }}>
140
  API Docs
141
  </a>
 
187
  {activeTab === 'youtube' && (
188
  <InlineResult
189
  {...resultProps}
190
+ loadingLabel={streamingText ? 'Generating…' : 'Fetching transcript…'}
191
  placeholderText="Fetching transcript…"
192
  />
193
  )}
 
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...</>
frontend/src/components/InlineResult.jsx CHANGED
@@ -18,14 +18,16 @@ export default function InlineResult({ error, loading, response, streamingText,
18
  {streamingText ? 'Generating…' : (loadingLabel || 'Processing…')}
19
  <span className="response-badge" style={{ marginLeft: 'auto' }}>{selectedModel}</span>
20
  </div>
21
- <p className="inline-result__text">
22
- {streamingText || (
 
 
23
  <span className="streaming-placeholder">
24
  {placeholderText || loadingLabel || 'Waiting for model…'}
25
  </span>
26
  )}
27
  <span className="streaming-cursor">▌</span>
28
- </p>
29
  </div>
30
  )}
31
  {response && !loading && (
@@ -38,11 +40,7 @@ export default function InlineResult({ error, loading, response, streamingText,
38
  <span className="response-badge" style={{ marginLeft: 'auto' }}>{response.model ?? 'phi4-mini'}</span>
39
  </div>
40
  <div className="inline-result__text">
41
- <ReactMarkdown
42
- components={{ p: ({ children }) => <span>{children}</span> }}
43
- >
44
- {response.summary}
45
- </ReactMarkdown>
46
  </div>
47
  </div>
48
  )}
 
18
  {streamingText ? 'Generating…' : (loadingLabel || 'Processing…')}
19
  <span className="response-badge" style={{ marginLeft: 'auto' }}>{selectedModel}</span>
20
  </div>
21
+ <div className="inline-result__text inline-result__text--streaming">
22
+ {streamingText ? (
23
+ <ReactMarkdown>{streamingText}</ReactMarkdown>
24
+ ) : (
25
  <span className="streaming-placeholder">
26
  {placeholderText || loadingLabel || 'Waiting for model…'}
27
  </span>
28
  )}
29
  <span className="streaming-cursor">▌</span>
30
+ </div>
31
  </div>
32
  )}
33
  {response && !loading && (
 
40
  <span className="response-badge" style={{ marginLeft: 'auto' }}>{response.model ?? 'phi4-mini'}</span>
41
  </div>
42
  <div className="inline-result__text">
43
+ <ReactMarkdown>{response.summary}</ReactMarkdown>
 
 
 
 
44
  </div>
45
  </div>
46
  )}
frontend/src/hooks/useStreaming.js CHANGED
@@ -103,11 +103,19 @@ export function useStreaming() {
103
 
104
  setResponse({ summary, success: true, source_type: activeTab, model: selectedModel })
105
  } catch (err) {
 
 
 
 
106
  setError(err.message || 'An error occurred')
107
  } finally {
108
  setLoading(false)
109
  }
110
  }
111
 
112
- return { loading, response, error, streamingText, submit }
 
 
 
 
113
  }
 
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
 
116
+ const cancel = () => {
117
+ abortRef.current?.abort()
118
+ }
119
+
120
+ return { loading, response, error, streamingText, submit, cancel }
121
  }
init/README.md ADDED
File without changes
{src → init}/__init__.py RENAMED
@@ -1,5 +1,5 @@
1
  """
2
- Précis — Model loading, configuration, and fine-tuning utilities.
3
  """
4
 
5
  from src.config import ModelConfig, TrainingConfig, DataConfig
 
1
  """
2
+ Model loading, configuration, and fine-tuning utilities.
3
  """
4
 
5
  from src.config import ModelConfig, TrainingConfig, DataConfig
{src → init}/config.py RENAMED
@@ -1,4 +1,4 @@
1
- """Configuration management for Précis."""
2
 
3
  from dataclasses import dataclass, field
4
  from typing import Optional, List
 
1
+ """Configuration management."""
2
 
3
  from dataclasses import dataclass, field
4
  from typing import Optional, List
{src → init}/model.py RENAMED
File without changes
{src → init}/tuning/__init__.py RENAMED
File without changes
{src → init}/tuning/data.py RENAMED
File without changes
{src → init}/tuning/lora.py RENAMED
File without changes
{src → init}/tuning/trainer.py RENAMED
File without changes
scripts/README.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Scripts
2
+
3
+ These are scripts that are run separetly from the app. They are used to collect data and fine-tune the model(s) being run for the actual app's functionality.
4
+
5
+ ## Pull.py
6
+
7
+ Pull.py pulls (downloads) different datasets that are known to be exemplary fine-tuners for the task of summarization in the way we're trying to perform it. It'll place the data in the data directory.
8
+
9
+ ## Cleaners
10
+
11
+ Cleaners are scripts that are used to clean up the data that is collected. They are used to remove any irrelevant information from the data. The data is then modified by hand to ensure even better performance.
12
+
13
+ ## Train.py
14
+
15
+ Train.py simply fine-tunes the model with the cleaned data, and then places the model in the trained_models directory.
scripts/clean.py CHANGED
@@ -1,5 +1,6 @@
1
  """
2
- Clean up the raw data files so as to curate specifically-required
 
3
  """
4
 
5
  import subprocess
 
1
  """
2
+ Clean up the raw data files so as to curate specifically-required
3
+ * Imports cleaners from the scripts/ folder
4
  """
5
 
6
  import subprocess
scripts/evaluate.py CHANGED
@@ -1,5 +1,5 @@
1
  #!/usr/bin/env python3
2
- """CLI evaluation script for Précis."""
3
 
4
  import argparse
5
  import logging
 
1
  #!/usr/bin/env python3
2
+ """CLI evaluation script for the models"""
3
 
4
  import argparse
5
  import logging