compendious commited on
Commit
2f317f9
·
1 Parent(s): d30214c

Such slow progress...

Browse files
.env.example CHANGED
@@ -1,10 +1,19 @@
 
 
1
  API_BASE_URL=http://localhost:8000 # Temporary btw
2
  OLLAMA_BASE_URL=http://127.0.0.1:11434 # Ollama's default
3
  PRECIS_ALLOWED_ORIGINS=http://localhost:5173 # Just front end, might make it more/less strict but I just need something consistent rn
 
 
 
 
 
 
 
 
 
4
  PRECIS_API_KEY=replace-with-a-long-random-secret # Once the API is actually up, this'll be needed
5
- PRECIS_DEFAULT_MODEL=phi4-mini:latest
6
- PRECIS_AVAILABLE_MODELS=phi4-mini:latest,qwen:4b # Only here so both front and backend have it
7
  MAX_SUMMARY_TOKENS=120
8
- TEMPERATURE=0.2 # Random choice right now, will probably tweak
9
  PRECIS_MAX_UPLOAD_BYTES=10485760
10
  PRECIS_MAX_TRANSCRIPT_CHARS=250000
 
1
+
2
+ # Frontend/backend stuff
3
  API_BASE_URL=http://localhost:8000 # Temporary btw
4
  OLLAMA_BASE_URL=http://127.0.0.1:11434 # Ollama's default
5
  PRECIS_ALLOWED_ORIGINS=http://localhost:5173 # Just front end, might make it more/less strict but I just need something consistent rn
6
+
7
+ # Model addresses
8
+ DEFAULT_MODEL=phi4-mini:latest
9
+
10
+ # Update this if you add/remove models
11
+ # Or if you custom-name any of them
12
+ AVAILABLE_MODELS=phi4-mini:latest,qwen3:4b # Only here so both front and backend have it
13
+
14
+ # API stuff
15
  PRECIS_API_KEY=replace-with-a-long-random-secret # Once the API is actually up, this'll be needed
 
 
16
  MAX_SUMMARY_TOKENS=120
17
+ TEMPERATURE=0.2 # Randomly selected, will probably tweak later
18
  PRECIS_MAX_UPLOAD_BYTES=10485760
19
  PRECIS_MAX_TRANSCRIPT_CHARS=250000
.github/workflows/hf.yml CHANGED
@@ -12,4 +12,4 @@ jobs:
12
  - name: Push to hub
13
  env:
14
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
15
- run: git push --force https://compendious:${HF_TOKEN}@huggingface.co/spaces/compendious/precis main
 
12
  - name: Push to hub
13
  env:
14
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
15
+ run: git push https://compendious:${HF_TOKEN}@huggingface.co/spaces/compendious/precis main
.gitignore CHANGED
@@ -3,10 +3,18 @@
3
  **cache**
4
  *.ipynb
5
  *.venv
 
 
 
6
  .env
7
  .env.*
8
  !.env.example
9
 
 
 
 
 
 
10
  # Front end
11
  node_modules
12
  package-lock.json
 
3
  **cache**
4
  *.ipynb
5
  *.venv
6
+ temp.py
7
+
8
+ # Secrets
9
  .env
10
  .env.*
11
  !.env.example
12
 
13
+ # Leftovers
14
+ *.csv
15
+ *.json*
16
+ /*data*/
17
+
18
  # Front end
19
  node_modules
20
  package-lock.json
README.md CHANGED
@@ -27,27 +27,27 @@ All `/summarize/*` endpoints accept an optional `model` field to override the de
27
 
28
  ### Prerequisites
29
 
30
- - Python 3.11+
31
- - Node.js 18+ (or [Bun](https://bun.sh))
32
- - [Ollama](https://ollama.com) installed and running (`ollama serve`)
33
- - At least one model pulled: `ollama pull phi4-mini:latest`
34
 
35
  ### Run the Fine-Tuning
36
 
37
- Follow the scripts in `scripts/`, using any model you prefer. This project has been primarily tested with phi4-mini (from Microsoft) and Qwen 3-3b (from Alibaba).
38
 
39
- ### Backend
40
 
41
  ```bash
42
- cd backend
43
  # Create a venv or conda environment or whatever else you may want
44
  pip install -r ../requirements.txt
 
45
  uvicorn app:app --reload
46
  ```
47
 
48
  Runs on `http://localhost:8000`. Interactive docs at `/docs`.
49
 
50
- ### Frontend
51
 
52
  ```bash
53
  cd frontend
 
27
 
28
  ### Prerequisites
29
 
30
+ - Python 3.11+,
31
+ - Node.js 18+ (or an alternative like [Bun](https://bun.sh)),
32
+ - [Ollama](https://ollama.com) installed and running (`ollama serve` is the command, although it may be on auto-start).
33
+ - At least one model pulled: `ollama pull phi4-mini:latest` (for example)
34
 
35
  ### Run the Fine-Tuning
36
 
37
+ Follow the scripts in `scripts/`, using any model you prefer. This project has been primarily tested with phi4-mini (from Microsoft) and Qwen 3-4b (from Alibaba) (`ollama pull qwen3:4b` to pull it).
38
 
39
+ ### Start the Backend
40
 
41
  ```bash
 
42
  # Create a venv or conda environment or whatever else you may want
43
  pip install -r ../requirements.txt
44
+ cd backend
45
  uvicorn app:app --reload
46
  ```
47
 
48
  Runs on `http://localhost:8000`. Interactive docs at `/docs`.
49
 
50
+ ### Run the Frontend
51
 
52
  ```bash
53
  cd frontend
backend/app.py CHANGED
@@ -42,6 +42,16 @@ def verify_api_key(x_api_key: Optional[str] = Header(default=None, alias="X-API-
42
  raise HTTPException(status_code=401, detail="Invalid API key.")
43
 
44
 
 
 
 
 
 
 
 
 
 
 
45
  @app.get("/health")
46
  async def health():
47
  return {"status": "healthy", "service": "precis"}
@@ -68,6 +78,18 @@ async def status():
68
 
69
  @app.get("/models")
70
  async def list_models():
 
 
 
 
 
 
 
 
 
 
 
 
71
  return {"default": DEFAULT_MODEL, "available": AVAILABLE_MODELS}
72
 
73
 
 
42
  raise HTTPException(status_code=401, detail="Invalid API key.")
43
 
44
 
45
+ @app.get("/")
46
+ async def root():
47
+ return {
48
+ "service": "Précis API",
49
+ "docs": "/docs",
50
+ "health": "/health",
51
+ "status": "/status",
52
+ }
53
+
54
+
55
  @app.get("/health")
56
  async def health():
57
  return {"status": "healthy", "service": "precis"}
 
78
 
79
  @app.get("/models")
80
  async def list_models():
81
+ try:
82
+ async with httpx.AsyncClient(timeout=5.0) as client:
83
+ r = await client.get(f"{OLLAMA_BASE_URL}/api/tags")
84
+ r.raise_for_status()
85
+ payload = r.json() if r.content else {}
86
+ installed = [m.get("name") for m in payload.get("models", []) if m.get("name")]
87
+ if installed:
88
+ default = DEFAULT_MODEL if DEFAULT_MODEL in installed else installed[0]
89
+ return {"default": default, "available": installed}
90
+ except Exception:
91
+ pass
92
+
93
  return {"default": DEFAULT_MODEL, "available": AVAILABLE_MODELS}
94
 
95
 
backend/config.py CHANGED
@@ -23,9 +23,9 @@ def _required_env(name: str) -> str:
23
  return value
24
 
25
 
26
- OLLAMA_BASE_URL = _required_env("PRECIS_OLLAMA_BASE_URL")
27
- DEFAULT_MODEL = _required_env("PRECIS_DEFAULT_MODEL")
28
- AVAILABLE_MODELS = _csv_env("PRECIS_AVAILABLE_MODELS", [DEFAULT_MODEL])
29
  if DEFAULT_MODEL not in AVAILABLE_MODELS:
30
  AVAILABLE_MODELS = [DEFAULT_MODEL, *AVAILABLE_MODELS]
31
 
@@ -35,7 +35,11 @@ if not ALLOWED_ORIGINS:
35
 
36
  API_KEY = _required_env("PRECIS_API_KEY")
37
 
38
- MAX_SUMMARY_TOKENS = int(os.getenv("PRECIS_MAX_SUMMARY_TOKENS", "120"))
39
- TEMPERATURE = float(os.getenv("PRECIS_TEMPERATURE", "0.2"))
 
 
 
 
40
  MAX_UPLOAD_BYTES = int(os.getenv("PRECIS_MAX_UPLOAD_BYTES", "10485760"))
41
  MAX_TRANSCRIPT_CHARS = int(os.getenv("PRECIS_MAX_TRANSCRIPT_CHARS", "120000"))
 
23
  return value
24
 
25
 
26
+ OLLAMA_BASE_URL = _required_env("OLLAMA_BASE_URL")
27
+ DEFAULT_MODEL = _required_env("DEFAULT_MODEL")
28
+ AVAILABLE_MODELS = _csv_env("AVAILABLE_MODELS", [DEFAULT_MODEL])
29
  if DEFAULT_MODEL not in AVAILABLE_MODELS:
30
  AVAILABLE_MODELS = [DEFAULT_MODEL, *AVAILABLE_MODELS]
31
 
 
35
 
36
  API_KEY = _required_env("PRECIS_API_KEY")
37
 
38
+ MAX_SUMMARY_TOKENS = int(
39
+ os.getenv("MAX_SUMMARY_TOKENS", os.getenv("PRECIS_MAX_SUMMARY_TOKENS", "120"))
40
+ )
41
+ TEMPERATURE = float(
42
+ os.getenv("TEMPERATURE", os.getenv("PRECIS_TEMPERATURE", "0.2"))
43
+ )
44
  MAX_UPLOAD_BYTES = int(os.getenv("PRECIS_MAX_UPLOAD_BYTES", "10485760"))
45
  MAX_TRANSCRIPT_CHARS = int(os.getenv("PRECIS_MAX_TRANSCRIPT_CHARS", "120000"))
backend/ollama.py CHANGED
@@ -1,4 +1,6 @@
1
  from typing import Optional
 
 
2
 
3
  import httpx
4
  from fastapi import HTTPException
@@ -25,50 +27,115 @@ def build_prompt(title: Optional[str], text: str) -> str:
25
  )
26
  return (
27
  f"{instructions}\n"
28
- "Do not add opinions, commentary, or filler phrases like 'The article discusses'.\n"
29
- "Output the summary sentence only nothing else.\n\n"
 
30
  f"Article:\n{text}\n\n"
31
  "Summary:"
32
  )
33
 
34
 
35
  def resolve_model(model: Optional[str]) -> str:
36
- if not model:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  return DEFAULT_MODEL
38
- if model not in AVAILABLE_MODELS:
39
  raise HTTPException(
40
  status_code=400,
41
- detail=f"Unknown model '{model}'. Available: {AVAILABLE_MODELS}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  )
43
- return model
44
 
45
 
46
  async def ollama_stream(prompt: str, model: str):
47
- """Async generator: yields raw NDJSON lines from Ollama."""
 
 
 
48
  payload = {
49
  "model": model,
50
  "prompt": prompt,
51
  "stream": True,
 
52
  "options": {
53
- "num_predict": MAX_SUMMARY_TOKENS,
54
  "temperature": TEMPERATURE,
55
- "stop": ["\n\n", "Article:", "Title:"],
56
  },
57
  }
58
- async with httpx.AsyncClient(timeout=120.0) as client:
59
  try:
60
  async with client.stream(
61
  "POST", f"{OLLAMA_BASE_URL}/api/generate", json=payload,
62
  ) as resp:
63
  resp.raise_for_status()
64
  async for line in resp.aiter_lines():
65
- if line:
 
 
 
 
 
 
 
66
  yield line + "\n"
67
  except httpx.ConnectError:
68
- raise HTTPException(
69
- status_code=503,
70
- detail="Cannot reach Ollama. Make sure `ollama serve` is running.",
71
- )
 
 
 
 
 
 
 
 
 
 
72
 
73
 
74
  def stream_summary(
@@ -77,6 +144,7 @@ def stream_summary(
77
  model: Optional[str] = None,
78
  ) -> StreamingResponse:
79
  """Universal funnel: text -> prompt -> Ollama stream -> NDJSON response."""
 
80
  resolved = resolve_model(model)
81
  prompt = build_prompt(title, text)
82
  return StreamingResponse(
 
1
  from typing import Optional
2
+ import json
3
+ import os
4
 
5
  import httpx
6
  from fastapi import HTTPException
 
27
  )
28
  return (
29
  f"{instructions}\n"
30
+ "Do not add opinions, commentary, or filler phrases like 'The article discusses' or 'This document provides'.\n"
31
+ "or ANYTHING along those lines whether it be in meaning or phrasing. "
32
+ "Output the summary sentence only. The sentence should be no longer than 200 characetrs long. Nothing else should be included.\n\n"
33
  f"Article:\n{text}\n\n"
34
  "Summary:"
35
  )
36
 
37
 
38
  def resolve_model(model: Optional[str]) -> str:
39
+ requested = model or ""
40
+
41
+ # Prefer what Ollama actually has installed.
42
+ try:
43
+ with httpx.Client(timeout=5.0) as client:
44
+ r = client.get(f"{OLLAMA_BASE_URL}/api/tags")
45
+ r.raise_for_status()
46
+ payload = r.json() if r.content else {}
47
+ installed = [m.get("name") for m in payload.get("models", []) if m.get("name")]
48
+ except Exception:
49
+ installed = []
50
+
51
+ if installed:
52
+ if not requested:
53
+ return DEFAULT_MODEL if DEFAULT_MODEL in installed else installed[0]
54
+ if requested not in installed:
55
+ raise HTTPException(
56
+ status_code=400,
57
+ detail=(
58
+ f"Model '{requested}' is not installed in Ollama. "
59
+ f"Installed: {installed}. Run `ollama pull {requested}`."
60
+ ),
61
+ )
62
+ return requested
63
+
64
+ # Fallback: use configured allowlist when Ollama isn't reachable.
65
+ if not requested:
66
  return DEFAULT_MODEL
67
+ if requested not in AVAILABLE_MODELS:
68
  raise HTTPException(
69
  status_code=400,
70
+ detail=f"Unknown model '{requested}'. Available: {AVAILABLE_MODELS}",
71
+ )
72
+ return requested
73
+
74
+
75
+ def ensure_ollama_reachable() -> None:
76
+ try:
77
+ with httpx.Client(timeout=10.0) as client:
78
+ response = client.get(f"{OLLAMA_BASE_URL}/api/tags")
79
+ response.raise_for_status()
80
+ except httpx.ConnectError:
81
+ raise HTTPException(
82
+ status_code=503,
83
+ detail="Cannot reach Ollama. Make sure `ollama serve` is running.",
84
+ )
85
+ except httpx.HTTPError as exc:
86
+ raise HTTPException(
87
+ status_code=503,
88
+ detail=f"Ollama responded with an error: {exc}",
89
  )
 
90
 
91
 
92
  async def ollama_stream(prompt: str, model: str):
93
+ """Async generator: yields NDJSON lines from Ollama, filtering out thinking-only chunks."""
94
+ keep_alive = os.getenv("OLLAMA_KEEP_ALIVE", "30m")
95
+ # Set num_predict high so thinking tokens don't limit output.
96
+ num_predict = MAX_SUMMARY_TOKENS * 3
97
  payload = {
98
  "model": model,
99
  "prompt": prompt,
100
  "stream": True,
101
+ "keep_alive": keep_alive,
102
  "options": {
103
+ "num_predict": num_predict,
104
  "temperature": TEMPERATURE,
105
+ "stop": ["Article:", "Title:"],
106
  },
107
  }
108
+ async with httpx.AsyncClient(timeout=300.0) as client:
109
  try:
110
  async with client.stream(
111
  "POST", f"{OLLAMA_BASE_URL}/api/generate", json=payload,
112
  ) as resp:
113
  resp.raise_for_status()
114
  async for line in resp.aiter_lines():
115
+ if not line:
116
+ continue
117
+ try:
118
+ chunk = json.loads(line)
119
+ # Skips thinking-only chunks.
120
+ if chunk.get("response"):
121
+ yield line + "\n"
122
+ except json.JSONDecodeError:
123
  yield line + "\n"
124
  except httpx.ConnectError:
125
+ error_line = json.dumps({
126
+ "error": "Cannot reach Ollama. Make sure `ollama serve` is running.",
127
+ })
128
+ yield error_line + "\n"
129
+ except httpx.TimeoutException:
130
+ error_line = json.dumps({
131
+ "error": "Ollama timed out. The model may still be loading — try again in a moment.",
132
+ })
133
+ yield error_line + "\n"
134
+ except httpx.HTTPError as exc:
135
+ error_line = json.dumps({
136
+ "error": f"Ollama error: {exc}",
137
+ })
138
+ yield error_line + "\n"
139
 
140
 
141
  def stream_summary(
 
144
  model: Optional[str] = None,
145
  ) -> StreamingResponse:
146
  """Universal funnel: text -> prompt -> Ollama stream -> NDJSON response."""
147
+ ensure_ollama_reachable()
148
  resolved = resolve_model(model)
149
  prompt = build_prompt(title, text)
150
  return StreamingResponse(
frontend/src/App.jsx CHANGED
@@ -1,8 +1,8 @@
1
- import { useState, useRef } from 'react'
2
  import InlineResult from './components/InlineResult'
3
  import { useStreaming } from './hooks/useStreaming'
4
  import logoSvg from './assets/logo.svg'
5
- import { API_BASE, AVAILABLE_MODELS, DEFAULT_MODEL } from './config'
6
  import './App.css'
7
 
8
  function App() {
@@ -10,13 +10,40 @@ function App() {
10
  const [youtubeUrl, setYoutubeUrl] = useState('')
11
  const [transcript, setTranscript] = useState('')
12
  const [selectedFile, setSelectedFile] = useState(null)
13
- const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL)
 
14
  const fileInputRef = useRef(null)
15
 
16
  const { loading, response, error, streamingText, submit } = useStreaming()
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  const handleSubmit = () =>
19
- submit(activeTab, { youtubeUrl, transcript, selectedFile, selectedModel })
 
 
 
 
 
20
 
21
  const handleFileDrop = (e) => {
22
  e.preventDefault()
@@ -50,9 +77,9 @@ function App() {
50
  className="model-select"
51
  value={selectedModel}
52
  onChange={(e) => setSelectedModel(e.target.value)}
53
- disabled={loading}
54
  >
55
- {AVAILABLE_MODELS.map((m) => <option key={m} value={m}>{m}</option>)}
56
  </select>
57
  <a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer" className="btn" style={{ textDecoration: 'none' }}>
58
  API Docs
@@ -102,7 +129,13 @@ function App() {
102
  />
103
  <p className="form-hint">Paste a YouTube URL. Ctrl+Enter to generate.</p>
104
  </div>
105
- {activeTab === 'youtube' && <InlineResult {...resultProps} loadingLabel="Fetching transcript…" />}
 
 
 
 
 
 
106
  </div>
107
 
108
  {/* Transcript */}
@@ -124,7 +157,13 @@ function App() {
124
  {' '}to generate.
125
  </p>
126
  </div>
127
- {activeTab === 'transcript' && <InlineResult {...resultProps} loadingLabel="Generating…" />}
 
 
 
 
 
 
128
  </div>
129
 
130
  {/* File upload */}
@@ -162,7 +201,13 @@ function App() {
162
  </div>
163
  )}
164
  </div>
165
- {activeTab === 'file' && <InlineResult {...resultProps} loadingLabel="Reading file…" />}
 
 
 
 
 
 
166
  </div>
167
 
168
  <div className="submit-section">
 
1
+ import { useEffect, useState, useRef } from 'react'
2
  import InlineResult from './components/InlineResult'
3
  import { useStreaming } from './hooks/useStreaming'
4
  import logoSvg from './assets/logo.svg'
5
+ import { API_BASE } from './config'
6
  import './App.css'
7
 
8
  function App() {
 
10
  const [youtubeUrl, setYoutubeUrl] = useState('')
11
  const [transcript, setTranscript] = useState('')
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
21
+ ;(async () => {
22
+ try {
23
+ const res = await fetch(`${API_BASE}/models`)
24
+ if (!res.ok) return
25
+ const data = await res.json()
26
+ if (cancelled) return
27
+
28
+ const available = Array.isArray(data.available) ? data.available : []
29
+ setModels(available)
30
+
31
+ const serverDefault = typeof data.default === 'string' ? data.default : ''
32
+ setSelectedModel((prev) => prev || serverDefault || available[0] || '')
33
+ } catch {
34
+ // Non-fatal: model list stays empty; backend will still pick default if model omitted.
35
+ }
36
+ })()
37
+ return () => { cancelled = true }
38
+ }, [])
39
+
40
  const handleSubmit = () =>
41
+ submit(activeTab, {
42
+ youtubeUrl,
43
+ transcript,
44
+ selectedFile,
45
+ selectedModel: selectedModel || undefined,
46
+ })
47
 
48
  const handleFileDrop = (e) => {
49
  e.preventDefault()
 
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
 
129
  />
130
  <p className="form-hint">Paste a YouTube URL. Ctrl+Enter to generate.</p>
131
  </div>
132
+ {activeTab === 'youtube' && (
133
+ <InlineResult
134
+ {...resultProps}
135
+ loadingLabel="Fetching transcript…"
136
+ placeholderText="Fetching transcript…"
137
+ />
138
+ )}
139
  </div>
140
 
141
  {/* Transcript */}
 
157
  {' '}to generate.
158
  </p>
159
  </div>
160
+ {activeTab === 'transcript' && (
161
+ <InlineResult
162
+ {...resultProps}
163
+ loadingLabel="Generating…"
164
+ placeholderText="Waiting for model…"
165
+ />
166
+ )}
167
  </div>
168
 
169
  {/* File upload */}
 
201
  </div>
202
  )}
203
  </div>
204
+ {activeTab === 'file' && (
205
+ <InlineResult
206
+ {...resultProps}
207
+ loadingLabel="Reading file…"
208
+ placeholderText="Reading file…"
209
+ />
210
+ )}
211
  </div>
212
 
213
  <div className="submit-section">
frontend/src/components/InlineResult.jsx CHANGED
@@ -1,4 +1,4 @@
1
- export default function InlineResult({ error, loading, response, streamingText, selectedModel, loadingLabel }) {
2
  return (
3
  <>
4
  {error && (
@@ -17,7 +17,11 @@ export default function InlineResult({ error, loading, response, streamingText,
17
  <span className="response-badge" style={{ marginLeft: 'auto' }}>{selectedModel}</span>
18
  </div>
19
  <p className="inline-result__text">
20
- {streamingText || <span className="streaming-placeholder">Waiting for model…</span>}
 
 
 
 
21
  <span className="streaming-cursor">▌</span>
22
  </p>
23
  </div>
 
1
+ export default function InlineResult({ error, loading, response, streamingText, selectedModel, loadingLabel, placeholderText }) {
2
  return (
3
  <>
4
  {error && (
 
17
  <span className="response-badge" style={{ marginLeft: 'auto' }}>{selectedModel}</span>
18
  </div>
19
  <p className="inline-result__text">
20
+ {streamingText || (
21
+ <span className="streaming-placeholder">
22
+ {placeholderText || loadingLabel || 'Waiting for model…'}
23
+ </span>
24
+ )}
25
  <span className="streaming-cursor">▌</span>
26
  </p>
27
  </div>
frontend/src/config.js CHANGED
@@ -1,24 +1,21 @@
1
- const parseCsv = (raw, fallback = []) => {
2
- if (!raw || !raw.trim()) return fallback
3
- return raw.split(',').map((part) => part.trim()).filter(Boolean)
4
- }
5
-
6
- const requiredEnv = (name) => {
7
- const value = import.meta.env[name]
8
- if (!value || !String(value).trim()) {
9
- throw new Error(`Missing required environment variable: ${name}`)
10
  }
11
- return String(value).trim()
12
  }
13
 
14
- export const API_BASE = requiredEnv('PRECIS_API_BASE_URL')
15
- export const API_KEY = requiredEnv('PRECIS_API_KEY')
16
-
17
- export const DEFAULT_MODEL = requiredEnv('PRECIS_DEFAULT_MODEL')
18
- export const AVAILABLE_MODELS = parseCsv(
19
- import.meta.env.PRECIS_AVAILABLE_MODELS,
20
- [DEFAULT_MODEL],
21
- )
 
22
 
23
  export const authHeaders = (headers = {}) => (
24
  API_KEY ? { ...headers, 'X-API-Key': API_KEY } : headers
 
1
+ const requiredEnv = (names) => {
2
+ const list = Array.isArray(names) ? names : [names]
3
+ for (const name of list) {
4
+ const value = import.meta.env[name]
5
+ if (value && String(value).trim()) return String(value).trim()
 
 
 
 
6
  }
7
+ throw new Error(`Missing required environment variable. Tried: ${list.join(', ')}`)
8
  }
9
 
10
+ export const API_BASE = requiredEnv([
11
+ 'API_BASE_URL',
12
+ 'VITE_API_BASE_URL',
13
+ 'PRECIS_API_BASE_URL',
14
+ ])
15
+ export const API_KEY = requiredEnv([
16
+ 'PRECIS_API_KEY',
17
+ 'VITE_API_KEY',
18
+ ])
19
 
20
  export const authHeaders = (headers = {}) => (
21
  API_KEY ? { ...headers, 'X-API-Key': API_KEY } : headers
frontend/src/hooks/useStreaming.js CHANGED
@@ -13,6 +13,7 @@ export function useStreaming() {
13
  const decoder = new TextDecoder()
14
  let accumulated = ''
15
  let buffer = ''
 
16
 
17
  while (true) {
18
  const { done, value } = await reader.read()
@@ -26,6 +27,10 @@ export function useStreaming() {
26
  if (!line.trim()) continue
27
  try {
28
  const chunk = JSON.parse(line)
 
 
 
 
29
  if (chunk.response) {
30
  accumulated += chunk.response
31
  setStreamingText(accumulated)
@@ -34,7 +39,16 @@ export function useStreaming() {
34
  }
35
  }
36
 
37
- return accumulated.trim()
 
 
 
 
 
 
 
 
 
38
  }
39
 
40
  const streamFrom = async (endpoint, { json, formData } = {}) => {
 
13
  const decoder = new TextDecoder()
14
  let accumulated = ''
15
  let buffer = ''
16
+ let streamError = null
17
 
18
  while (true) {
19
  const { done, value } = await reader.read()
 
27
  if (!line.trim()) continue
28
  try {
29
  const chunk = JSON.parse(line)
30
+ if (chunk.error) {
31
+ streamError = String(chunk.error)
32
+ continue
33
+ }
34
  if (chunk.response) {
35
  accumulated += chunk.response
36
  setStreamingText(accumulated)
 
39
  }
40
  }
41
 
42
+ if (streamError) {
43
+ throw new Error(streamError)
44
+ }
45
+
46
+ const finalText = accumulated.trim()
47
+ if (!finalText) {
48
+ throw new Error('Model returned an empty response. Try again or pick a different model.')
49
+ }
50
+
51
+ return finalText
52
  }
53
 
54
  const streamFrom = async (endpoint, { json, formData } = {}) => {
frontend/vite.config.js CHANGED
@@ -4,6 +4,6 @@ import react from '@vitejs/plugin-react'
4
  // https://vite.dev/config/
5
  export default defineConfig({
6
  envDir: '..',
7
- envPrefix: ['VITE_', 'PRECIS_'],
8
  plugins: [react()],
9
  })
 
4
  // https://vite.dev/config/
5
  export default defineConfig({
6
  envDir: '..',
7
+ envPrefix: ['VITE_', 'PRECIS_', 'API_', 'DEFAULT_', 'AVAILABLE_'],
8
  plugins: [react()],
9
  })
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- # ML Training vibes
2
  torch
3
  transformers
4
  accelerate
@@ -10,8 +10,10 @@ sentencepiece
10
  # API
11
  fastapi
12
  uvicorn
13
- httpx # async HTTP client for Ollama calls
14
- python-multipart # required by FastAPI for file uploads
15
- youtube-transcript-api # YouTube transcript fetching
16
- python-dotenv # .env loading for backend/frontend config
17
 
 
 
 
 
 
1
+ # Model Trainerz
2
  torch
3
  transformers
4
  accelerate
 
10
  # API
11
  fastapi
12
  uvicorn
13
+ httpx # async HTTP client for Ollama calls
14
+ python-multipart # faster file uploads
 
 
15
 
16
+ python-dotenv
17
+
18
+ # Prop tools
19
+ youtube-transcript-api