github-actions[bot] commited on
Commit
46e5b37
·
1 Parent(s): 5250342

Sync from GitHub 214f9ed998ff8d82e81656fab8d69dcd637cd425

Browse files
app.py CHANGED
@@ -197,7 +197,7 @@ MAX_HISTORY_MESSAGES = 8
197
  MAX_HISTORY_CHARS_PER_MESSAGE = 1000
198
  MAX_UPLOAD_FILENAME_LENGTH = 255
199
  SAFE_FILENAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
200
- UPLOADS_ROOT = Path("uploads")
201
 
202
 
203
  def _build_conversation_history(
@@ -287,13 +287,51 @@ def _remove_tree_within_root(root: Path, target: Path) -> None:
287
  target_resolved = target.resolve()
288
  if target_resolved == root_resolved or root_resolved not in target_resolved.parents:
289
  raise RuntimeError(f"Refusing to delete path outside root: {target_resolved}")
290
- shutil.rmtree(target_resolved)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
 
293
  def _cleanup_notebook_storage(owner_user_id: int, notebook_id: int) -> None:
294
  storage_base = Path(os.getenv("STORAGE_BASE_DIR", "data"))
295
  notebook_root = storage_base / "users" / str(owner_user_id) / "notebooks"
296
  notebook_path = notebook_root / str(notebook_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  _remove_tree_within_root(notebook_root, notebook_path)
298
 
299
  upload_path = UPLOADS_ROOT / f"notebook_{notebook_id}"
 
197
  MAX_HISTORY_CHARS_PER_MESSAGE = 1000
198
  MAX_UPLOAD_FILENAME_LENGTH = 255
199
  SAFE_FILENAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
200
+ UPLOADS_ROOT = Path(os.getenv("STORAGE_BASE_DIR", "data")) / "uploads"
201
 
202
 
203
  def _build_conversation_history(
 
287
  target_resolved = target.resolve()
288
  if target_resolved == root_resolved or root_resolved not in target_resolved.parents:
289
  raise RuntimeError(f"Refusing to delete path outside root: {target_resolved}")
290
+
291
+ def _onerror(_func: Any, path: str, _exc_info: Any) -> None:
292
+ """Handle Windows locked / read-only files by forcing writable."""
293
+ try:
294
+ os.chmod(path, 0o777)
295
+ os.remove(path)
296
+ except OSError:
297
+ pass
298
+
299
+ # Try up to 3 times to handle file locks (e.g. ChromaDB on Windows)
300
+ import gc
301
+ for attempt in range(3):
302
+ try:
303
+ shutil.rmtree(target_resolved, onerror=_onerror)
304
+ return
305
+ except OSError:
306
+ if attempt == 2:
307
+ raise
308
+ gc.collect()
309
+ import time
310
+ time.sleep(0.5)
311
 
312
 
313
  def _cleanup_notebook_storage(owner_user_id: int, notebook_id: int) -> None:
314
  storage_base = Path(os.getenv("STORAGE_BASE_DIR", "data"))
315
  notebook_root = storage_base / "users" / str(owner_user_id) / "notebooks"
316
  notebook_path = notebook_root / str(notebook_id)
317
+
318
+ # Release any ChromaDB connections to this notebook before deleting
319
+ chroma_dir = notebook_path / "chroma"
320
+ if chroma_dir.exists():
321
+ try:
322
+ from src.ingestion.vectorstore import ChromaAdapter
323
+ store = ChromaAdapter(persist_directory=str(chroma_dir))
324
+ collection_name = f"user_{owner_user_id}_notebook_{notebook_id}"
325
+ try:
326
+ store._client.delete_collection(collection_name)
327
+ except Exception:
328
+ pass
329
+ del store
330
+ import gc
331
+ gc.collect()
332
+ except Exception:
333
+ pass
334
+
335
  _remove_tree_within_root(notebook_root, notebook_path)
336
 
337
  upload_path = UPLOADS_ROOT / f"notebook_{notebook_id}"
auth/__init__.py ADDED
File without changes
data/__init__.py ADDED
File without changes
data/crud.py CHANGED
@@ -1,11 +1,10 @@
1
  from __future__ import annotations
2
 
3
  from collections import defaultdict
4
- from datetime import datetime
5
- from data.models import Artifact
6
  from sqlalchemy.orm import Session
7
 
8
- from data.models import ChatThread, Message, MessageCitation, Notebook, Source, User
9
 
10
 
11
  def get_or_create_user(
@@ -296,7 +295,7 @@ def update_artifact(
296
  merged.update(metadata)
297
  artifact.artifact_metadata = merged
298
  if status == "ready":
299
- artifact.generated_at = datetime.utcnow()
300
 
301
  db.commit()
302
  db.refresh(artifact)
 
1
  from __future__ import annotations
2
 
3
  from collections import defaultdict
4
+ from datetime import datetime, timezone
 
5
  from sqlalchemy.orm import Session
6
 
7
+ from data.models import Artifact, ChatThread, Message, MessageCitation, Notebook, Source, User
8
 
9
 
10
  def get_or_create_user(
 
295
  merged.update(metadata)
296
  artifact.artifact_metadata = merged
297
  if status == "ready":
298
+ artifact.generated_at = datetime.now(timezone.utc)
299
 
300
  db.commit()
301
  db.refresh(artifact)
frontend/app.py CHANGED
@@ -161,6 +161,7 @@ def inject_theme() -> None:
161
  <style>
162
  @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
163
 
 
164
  :root {
165
  --ink: #162525;
166
  --ink-muted: #4f6362;
@@ -176,13 +177,89 @@ def inject_theme() -> None:
176
  --error-border: #d2675a;
177
  --pending-soft: #eceeef;
178
  --pending-border: #9aa7ad;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  }
180
 
181
  .stApp {
182
  background:
183
- radial-gradient(circle at 15% -5%, #fff5e6 0%, rgba(255, 245, 230, 0) 42%),
184
- radial-gradient(circle at 88% 8%, #dff2ee 0%, rgba(223, 242, 238, 0) 44%),
185
- linear-gradient(180deg, #f8faf8 0%, #f1f5f2 100%);
186
  color: var(--ink);
187
  font-family: "IBM Plex Sans", sans-serif;
188
  }
@@ -195,10 +272,11 @@ def inject_theme() -> None:
195
 
196
  p, li, label, [data-testid="stMarkdownContainer"] p {
197
  line-height: 1.5;
 
198
  }
199
 
200
  [data-testid="stSidebar"] {
201
- background: linear-gradient(180deg, #173436 0%, #11292b 100%);
202
  }
203
 
204
  [data-testid="stSidebar"] h1,
@@ -209,7 +287,7 @@ def inject_theme() -> None:
209
  [data-testid="stSidebar"] p,
210
  [data-testid="stSidebar"] small,
211
  [data-testid="stSidebar"] span {
212
- color: #e9f1ef;
213
  font-family: "IBM Plex Sans", sans-serif;
214
  }
215
 
@@ -217,13 +295,13 @@ def inject_theme() -> None:
217
  [data-testid="stSidebar"] .stTextArea textarea,
218
  [data-testid="stSidebar"] div[data-baseweb="select"] > div,
219
  [data-testid="stSidebar"] .stNumberInput input {
220
- background: rgba(233, 241, 239, 0.1);
221
- color: #f3f8f7;
222
- border: 1px solid rgba(178, 209, 202, 0.35);
223
  }
224
 
225
  [data-testid="stSidebar"] div[data-baseweb="select"] * {
226
- color: #0f2a27;
227
  }
228
 
229
  h1, h2, h3, h4 {
@@ -233,8 +311,8 @@ def inject_theme() -> None:
233
  }
234
 
235
  .app-hero {
236
- background: linear-gradient(125deg, rgba(20, 111, 103, 0.13) 0%, rgba(255, 245, 228, 0.95) 56%, rgba(205, 231, 226, 0.9) 100%);
237
- border: 1px solid #c9d9cf;
238
  border-radius: 18px;
239
  padding: 18px 22px;
240
  margin-bottom: 14px;
@@ -247,9 +325,9 @@ def inject_theme() -> None:
247
  font-size: 0.75rem;
248
  letter-spacing: 0.08em;
249
  text-transform: uppercase;
250
- color: #0f625b;
251
- background: rgba(255, 255, 255, 0.7);
252
- border: 1px solid rgba(15, 98, 91, 0.22);
253
  border-radius: 999px;
254
  padding: 3px 10px;
255
  margin-bottom: 8px;
@@ -260,6 +338,7 @@ def inject_theme() -> None:
260
  font-family: "Space Grotesk", sans-serif;
261
  font-size: clamp(1.45rem, 2.4vw, 2rem);
262
  line-height: 1.2;
 
263
  }
264
 
265
  .hero-sub {
@@ -269,10 +348,11 @@ def inject_theme() -> None:
269
  }
270
 
271
  .soft-card {
272
- background: rgba(255, 255, 255, 0.82);
273
  border: 1px solid var(--card-border);
274
  border-radius: 14px;
275
  padding: 12px 14px;
 
276
  }
277
 
278
  .metric-card {
@@ -307,22 +387,22 @@ def inject_theme() -> None:
307
  .status-ready {
308
  background: var(--ok-soft);
309
  border-color: var(--ok-border);
310
- color: #1c6b4d;
311
  }
312
  .status-failed {
313
  background: var(--error-soft);
314
  border-color: var(--error-border);
315
- color: #8d3329;
316
  }
317
  .status-processing {
318
  background: var(--warn-soft);
319
  border-color: var(--warn-border);
320
- color: #7d581a;
321
  }
322
  .status-pending {
323
  background: var(--pending-soft);
324
  border-color: var(--pending-border);
325
- color: #485a63;
326
  }
327
 
328
  [data-testid="stText"] {
@@ -338,18 +418,18 @@ def inject_theme() -> None:
338
  div.stFormSubmitButton > button,
339
  div.stDownloadButton > button {
340
  border-radius: 11px;
341
- border: 1px solid #0f5f57;
342
- background: linear-gradient(135deg, #167269 0%, #0f5f57 100%);
343
- color: #f4fffd !important;
344
  font-weight: 600;
345
  padding: 0.38rem 0.95rem;
346
  transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
347
- box-shadow: 0 3px 10px rgba(15, 95, 87, 0.22);
348
  }
349
  div.stButton > button *,
350
  div.stFormSubmitButton > button *,
351
  div.stDownloadButton > button * {
352
- color: #f4fffd !important;
353
  }
354
 
355
  div.stButton > button:hover,
@@ -357,7 +437,7 @@ def inject_theme() -> None:
357
  div.stDownloadButton > button:hover {
358
  transform: translateY(-1px);
359
  filter: brightness(1.03);
360
- box-shadow: 0 5px 14px rgba(15, 95, 87, 0.28);
361
  }
362
 
363
  div.stButton > button:focus,
@@ -370,44 +450,71 @@ def inject_theme() -> None:
370
  [data-testid="stSidebar"] div.stButton > button,
371
  [data-testid="stSidebar"] div.stFormSubmitButton > button,
372
  [data-testid="stSidebar"] div.stDownloadButton > button {
373
- background: linear-gradient(135deg, #ecf8f4 0%, #d8efe9 100%);
374
- color: #123f3b !important;
375
- border: 1px solid #90c6bb;
376
  box-shadow: none;
377
  }
378
  [data-testid="stSidebar"] div.stButton > button *,
379
  [data-testid="stSidebar"] div.stFormSubmitButton > button *,
380
  [data-testid="stSidebar"] div.stDownloadButton > button * {
381
- color: #123f3b !important;
382
  }
383
 
384
  [data-testid="stSidebar"] div.stButton > button:hover,
385
  [data-testid="stSidebar"] div.stFormSubmitButton > button:hover,
386
  [data-testid="stSidebar"] div.stDownloadButton > button:hover {
387
- background: linear-gradient(135deg, #f5fffc 0%, #e3f8f3 100%);
388
  }
389
 
390
  /* Streamlit base button variants (primary/secondary/tertiary) */
391
  button[data-testid^="stBaseButton"] {
392
- color: #143a36 !important;
393
  }
394
  [data-testid="stSidebar"] button[data-testid^="stBaseButton"] {
395
- color: #123f3b !important;
396
  }
397
  button[data-testid^="stBaseButton"] * {
398
  color: inherit !important;
399
  }
400
 
401
- /* Keep form controls readable across theme overrides */
402
  .stTextInput input,
403
  .stTextArea textarea,
404
  .stNumberInput input,
405
  div[data-baseweb="select"] > div {
406
- color: #162525 !important;
407
  }
408
  .stTextInput input::placeholder,
409
  .stTextArea textarea::placeholder {
410
- color: #5a6a69 !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  }
412
 
413
  [data-testid="stDataFrame"] {
@@ -646,7 +753,7 @@ if page == "Notebooks":
646
  with refresh_col:
647
  st.write("")
648
  st.write("")
649
- if st.button("Refresh notebooks", use_container_width=True):
650
  st.rerun()
651
 
652
  if submitted:
@@ -680,7 +787,7 @@ if page == "Notebooks":
680
 
681
  if notebooks:
682
  st.write("Your notebooks")
683
- st.dataframe(notebooks, use_container_width=True, hide_index=True)
684
 
685
  notebook_options = {
686
  f"{n['id']} - {n['title']}": n
@@ -825,10 +932,10 @@ if page == "Notebooks":
825
  if ok:
826
  st.success("File uploaded and source added.")
827
  st.json(create_result)
 
828
  else:
829
  st.error("Failed to upload file source.")
830
  st.code(str(create_result))
831
- st.rerun()
832
 
833
  if source_type == "url" and not source_url.strip():
834
  st.error("Please provide a URL when source type is 'url'.")
@@ -878,7 +985,7 @@ if page == "Notebooks":
878
  with source_metrics_3:
879
  render_metric_card("In Flight", processing_sources)
880
  st.write("Sources")
881
- st.dataframe(sources, use_container_width=True, hide_index=True)
882
  else:
883
  st.info("No sources yet for this notebook.")
884
  else:
@@ -952,7 +1059,7 @@ if page == "Notebooks":
952
  st.markdown(str(content))
953
  if role == "assistant" and isinstance(citations, list) and citations:
954
  with st.expander("Citations", expanded=False):
955
- st.dataframe(citations, use_container_width=True, hide_index=True)
956
  else:
957
  st.error("Failed to fetch messages.")
958
  st.code(str(message_result))
@@ -978,7 +1085,7 @@ if page == "Notebooks":
978
  citations = chat_result.get("citations", [])
979
  if citations:
980
  st.write("Citations")
981
- st.dataframe(citations, use_container_width=True)
982
  st.rerun()
983
  else:
984
  st.error("Chat request failed.")
@@ -1143,7 +1250,7 @@ if page == "Notebooks":
1143
  with artifact_metric_4:
1144
  render_metric_card("Failed", failed_count)
1145
 
1146
- st.dataframe(artifacts, use_container_width=True, hide_index=True)
1147
  selected_artifact = choose_artifact_for_notebook(
1148
  int(selected_notebook_id),
1149
  artifacts,
 
161
  <style>
162
  @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
163
 
164
+ /* ── Light-mode tokens (default) ── */
165
  :root {
166
  --ink: #162525;
167
  --ink-muted: #4f6362;
 
177
  --error-border: #d2675a;
178
  --pending-soft: #eceeef;
179
  --pending-border: #9aa7ad;
180
+ --app-bg: linear-gradient(180deg, #f8faf8 0%, #f1f5f2 100%);
181
+ --app-bg-overlay-1: radial-gradient(circle at 15% -5%, #fff5e6 0%, rgba(255, 245, 230, 0) 42%);
182
+ --app-bg-overlay-2: radial-gradient(circle at 88% 8%, #dff2ee 0%, rgba(223, 242, 238, 0) 44%);
183
+ --hero-bg: linear-gradient(125deg, rgba(20, 111, 103, 0.13) 0%, rgba(255, 245, 228, 0.95) 56%, rgba(205, 231, 226, 0.9) 100%);
184
+ --hero-border: #c9d9cf;
185
+ --hero-kicker-bg: rgba(255, 255, 255, 0.7);
186
+ --hero-kicker-color: #0f625b;
187
+ --hero-kicker-border: rgba(15, 98, 91, 0.22);
188
+ --soft-card-bg: rgba(255, 255, 255, 0.82);
189
+ --sidebar-bg: linear-gradient(180deg, #173436 0%, #11292b 100%);
190
+ --sidebar-text: #e9f1ef;
191
+ --sidebar-input-bg: rgba(233, 241, 239, 0.1);
192
+ --sidebar-input-border: rgba(178, 209, 202, 0.35);
193
+ --sidebar-btn-bg: linear-gradient(135deg, #ecf8f4 0%, #d8efe9 100%);
194
+ --sidebar-btn-color: #123f3b;
195
+ --sidebar-btn-border: #90c6bb;
196
+ --sidebar-btn-hover-bg: linear-gradient(135deg, #f5fffc 0%, #e3f8f3 100%);
197
+ --btn-bg: linear-gradient(135deg, #167269 0%, #0f5f57 100%);
198
+ --btn-color: #f4fffd;
199
+ --btn-border: #0f5f57;
200
+ --btn-shadow: rgba(15, 95, 87, 0.22);
201
+ --input-color: #162525;
202
+ --input-placeholder: #5a6a69;
203
+ --select-dropdown-color: #0f2a27;
204
+ --status-ready-color: #1c6b4d;
205
+ --status-failed-color: #8d3329;
206
+ --status-processing-color: #7d581a;
207
+ --status-pending-color: #485a63;
208
+ }
209
+
210
+ /* ── Dark-mode tokens ── */
211
+ @media (prefers-color-scheme: dark) {
212
+ :root {
213
+ --ink: #e4eceb;
214
+ --ink-muted: #9bb0ae;
215
+ --card: #1e2d2d;
216
+ --card-border: #3a4e4b;
217
+ --accent: #3dd4c6;
218
+ --accent-soft: #1a3d39;
219
+ --warn-soft: #3d3223;
220
+ --warn-border: #b89040;
221
+ --ok-soft: #1a3d2e;
222
+ --ok-border: #3d9a6a;
223
+ --error-soft: #3d2220;
224
+ --error-border: #c25a4e;
225
+ --pending-soft: #2a3235;
226
+ --pending-border: #6b7b82;
227
+ --app-bg: linear-gradient(180deg, #141e1e 0%, #0f1817 100%);
228
+ --app-bg-overlay-1: radial-gradient(circle at 15% -5%, rgba(50, 40, 20, 0.4) 0%, rgba(50, 40, 20, 0) 42%);
229
+ --app-bg-overlay-2: radial-gradient(circle at 88% 8%, rgba(20, 60, 55, 0.3) 0%, rgba(20, 60, 55, 0) 44%);
230
+ --hero-bg: linear-gradient(125deg, rgba(20, 111, 103, 0.2) 0%, rgba(30, 45, 45, 0.95) 56%, rgba(25, 60, 55, 0.9) 100%);
231
+ --hero-border: #2a3e3c;
232
+ --hero-kicker-bg: rgba(30, 45, 45, 0.7);
233
+ --hero-kicker-color: #5ce0d3;
234
+ --hero-kicker-border: rgba(60, 180, 165, 0.3);
235
+ --soft-card-bg: rgba(30, 45, 45, 0.7);
236
+ --sidebar-bg: linear-gradient(180deg, #0e1f20 0%, #0a1617 100%);
237
+ --sidebar-text: #c8dad6;
238
+ --sidebar-input-bg: rgba(200, 218, 214, 0.08);
239
+ --sidebar-input-border: rgba(120, 170, 160, 0.25);
240
+ --sidebar-btn-bg: linear-gradient(135deg, #1a3332 0%, #162c2b 100%);
241
+ --sidebar-btn-color: #b0d8cf;
242
+ --sidebar-btn-border: #3a6e64;
243
+ --sidebar-btn-hover-bg: linear-gradient(135deg, #1f3d3b 0%, #1a3634 100%);
244
+ --btn-bg: linear-gradient(135deg, #1a8a7e 0%, #167a70 100%);
245
+ --btn-color: #e8fff9;
246
+ --btn-border: #1a8a7e;
247
+ --btn-shadow: rgba(26, 138, 126, 0.3);
248
+ --input-color: #e0ece9;
249
+ --input-placeholder: #7a9490;
250
+ --select-dropdown-color: #e0ece9;
251
+ --status-ready-color: #6ee6b7;
252
+ --status-failed-color: #f49b8f;
253
+ --status-processing-color: #f0c56d;
254
+ --status-pending-color: #9bb0b5;
255
+ }
256
  }
257
 
258
  .stApp {
259
  background:
260
+ var(--app-bg-overlay-1),
261
+ var(--app-bg-overlay-2),
262
+ var(--app-bg);
263
  color: var(--ink);
264
  font-family: "IBM Plex Sans", sans-serif;
265
  }
 
272
 
273
  p, li, label, [data-testid="stMarkdownContainer"] p {
274
  line-height: 1.5;
275
+ color: var(--ink);
276
  }
277
 
278
  [data-testid="stSidebar"] {
279
+ background: var(--sidebar-bg);
280
  }
281
 
282
  [data-testid="stSidebar"] h1,
 
287
  [data-testid="stSidebar"] p,
288
  [data-testid="stSidebar"] small,
289
  [data-testid="stSidebar"] span {
290
+ color: var(--sidebar-text);
291
  font-family: "IBM Plex Sans", sans-serif;
292
  }
293
 
 
295
  [data-testid="stSidebar"] .stTextArea textarea,
296
  [data-testid="stSidebar"] div[data-baseweb="select"] > div,
297
  [data-testid="stSidebar"] .stNumberInput input {
298
+ background: var(--sidebar-input-bg);
299
+ color: var(--sidebar-text);
300
+ border: 1px solid var(--sidebar-input-border);
301
  }
302
 
303
  [data-testid="stSidebar"] div[data-baseweb="select"] * {
304
+ color: var(--select-dropdown-color);
305
  }
306
 
307
  h1, h2, h3, h4 {
 
311
  }
312
 
313
  .app-hero {
314
+ background: var(--hero-bg);
315
+ border: 1px solid var(--hero-border);
316
  border-radius: 18px;
317
  padding: 18px 22px;
318
  margin-bottom: 14px;
 
325
  font-size: 0.75rem;
326
  letter-spacing: 0.08em;
327
  text-transform: uppercase;
328
+ color: var(--hero-kicker-color);
329
+ background: var(--hero-kicker-bg);
330
+ border: 1px solid var(--hero-kicker-border);
331
  border-radius: 999px;
332
  padding: 3px 10px;
333
  margin-bottom: 8px;
 
338
  font-family: "Space Grotesk", sans-serif;
339
  font-size: clamp(1.45rem, 2.4vw, 2rem);
340
  line-height: 1.2;
341
+ color: var(--ink);
342
  }
343
 
344
  .hero-sub {
 
348
  }
349
 
350
  .soft-card {
351
+ background: var(--soft-card-bg);
352
  border: 1px solid var(--card-border);
353
  border-radius: 14px;
354
  padding: 12px 14px;
355
+ color: var(--ink);
356
  }
357
 
358
  .metric-card {
 
387
  .status-ready {
388
  background: var(--ok-soft);
389
  border-color: var(--ok-border);
390
+ color: var(--status-ready-color);
391
  }
392
  .status-failed {
393
  background: var(--error-soft);
394
  border-color: var(--error-border);
395
+ color: var(--status-failed-color);
396
  }
397
  .status-processing {
398
  background: var(--warn-soft);
399
  border-color: var(--warn-border);
400
+ color: var(--status-processing-color);
401
  }
402
  .status-pending {
403
  background: var(--pending-soft);
404
  border-color: var(--pending-border);
405
+ color: var(--status-pending-color);
406
  }
407
 
408
  [data-testid="stText"] {
 
418
  div.stFormSubmitButton > button,
419
  div.stDownloadButton > button {
420
  border-radius: 11px;
421
+ border: 1px solid var(--btn-border);
422
+ background: var(--btn-bg);
423
+ color: var(--btn-color) !important;
424
  font-weight: 600;
425
  padding: 0.38rem 0.95rem;
426
  transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
427
+ box-shadow: 0 3px 10px var(--btn-shadow);
428
  }
429
  div.stButton > button *,
430
  div.stFormSubmitButton > button *,
431
  div.stDownloadButton > button * {
432
+ color: var(--btn-color) !important;
433
  }
434
 
435
  div.stButton > button:hover,
 
437
  div.stDownloadButton > button:hover {
438
  transform: translateY(-1px);
439
  filter: brightness(1.03);
440
+ box-shadow: 0 5px 14px var(--btn-shadow);
441
  }
442
 
443
  div.stButton > button:focus,
 
450
  [data-testid="stSidebar"] div.stButton > button,
451
  [data-testid="stSidebar"] div.stFormSubmitButton > button,
452
  [data-testid="stSidebar"] div.stDownloadButton > button {
453
+ background: var(--sidebar-btn-bg);
454
+ color: var(--sidebar-btn-color) !important;
455
+ border: 1px solid var(--sidebar-btn-border);
456
  box-shadow: none;
457
  }
458
  [data-testid="stSidebar"] div.stButton > button *,
459
  [data-testid="stSidebar"] div.stFormSubmitButton > button *,
460
  [data-testid="stSidebar"] div.stDownloadButton > button * {
461
+ color: var(--sidebar-btn-color) !important;
462
  }
463
 
464
  [data-testid="stSidebar"] div.stButton > button:hover,
465
  [data-testid="stSidebar"] div.stFormSubmitButton > button:hover,
466
  [data-testid="stSidebar"] div.stDownloadButton > button:hover {
467
+ background: var(--sidebar-btn-hover-bg);
468
  }
469
 
470
  /* Streamlit base button variants (primary/secondary/tertiary) */
471
  button[data-testid^="stBaseButton"] {
472
+ color: var(--ink) !important;
473
  }
474
  [data-testid="stSidebar"] button[data-testid^="stBaseButton"] {
475
+ color: var(--sidebar-btn-color) !important;
476
  }
477
  button[data-testid^="stBaseButton"] * {
478
  color: inherit !important;
479
  }
480
 
481
+ /* Keep form controls readable across light/dark */
482
  .stTextInput input,
483
  .stTextArea textarea,
484
  .stNumberInput input,
485
  div[data-baseweb="select"] > div {
486
+ color: var(--input-color) !important;
487
  }
488
  .stTextInput input::placeholder,
489
  .stTextArea textarea::placeholder {
490
+ color: var(--input-placeholder) !important;
491
+ }
492
+
493
+ /* Chat message text */
494
+ [data-testid="stChatMessage"] p,
495
+ [data-testid="stChatMessage"] li,
496
+ [data-testid="stChatMessage"] span {
497
+ color: var(--ink) !important;
498
+ }
499
+
500
+ /* Expander text */
501
+ [data-testid="stExpander"] summary span {
502
+ color: var(--ink) !important;
503
+ }
504
+
505
+ /* Selectbox display text */
506
+ div[data-baseweb="select"] span {
507
+ color: var(--input-color) !important;
508
+ }
509
+
510
+ /* Tabs / segmented control labels */
511
+ [data-testid="stTabs"] button p {
512
+ color: var(--ink) !important;
513
+ }
514
+
515
+ /* Caption */
516
+ .stCaption, [data-testid="stCaptionContainer"] {
517
+ color: var(--ink-muted) !important;
518
  }
519
 
520
  [data-testid="stDataFrame"] {
 
753
  with refresh_col:
754
  st.write("")
755
  st.write("")
756
+ if st.button("Refresh notebooks", width="stretch"):
757
  st.rerun()
758
 
759
  if submitted:
 
787
 
788
  if notebooks:
789
  st.write("Your notebooks")
790
+ st.dataframe(notebooks, width="stretch", hide_index=True)
791
 
792
  notebook_options = {
793
  f"{n['id']} - {n['title']}": n
 
932
  if ok:
933
  st.success("File uploaded and source added.")
934
  st.json(create_result)
935
+ st.rerun()
936
  else:
937
  st.error("Failed to upload file source.")
938
  st.code(str(create_result))
 
939
 
940
  if source_type == "url" and not source_url.strip():
941
  st.error("Please provide a URL when source type is 'url'.")
 
985
  with source_metrics_3:
986
  render_metric_card("In Flight", processing_sources)
987
  st.write("Sources")
988
+ st.dataframe(sources, width="stretch", hide_index=True)
989
  else:
990
  st.info("No sources yet for this notebook.")
991
  else:
 
1059
  st.markdown(str(content))
1060
  if role == "assistant" and isinstance(citations, list) and citations:
1061
  with st.expander("Citations", expanded=False):
1062
+ st.dataframe(citations, width="stretch", hide_index=True)
1063
  else:
1064
  st.error("Failed to fetch messages.")
1065
  st.code(str(message_result))
 
1085
  citations = chat_result.get("citations", [])
1086
  if citations:
1087
  st.write("Citations")
1088
+ st.dataframe(citations, width="stretch")
1089
  st.rerun()
1090
  else:
1091
  st.error("Chat request failed.")
 
1250
  with artifact_metric_4:
1251
  render_metric_card("Failed", failed_count)
1252
 
1253
+ st.dataframe(artifacts, width="stretch", hide_index=True)
1254
  selected_artifact = choose_artifact_for_notebook(
1255
  int(selected_notebook_id),
1256
  artifacts,
requirements.txt CHANGED
@@ -7,12 +7,9 @@ python-multipart==0.0.20
7
  itsdangerous==2.2.0
8
  httpx==0.28.1
9
  openai>=1.0.0
10
- faiss-cpu==1.10.0
11
- pypdf==5.3.0
12
  requests==2.32.3
13
  streamlit==1.42.2
14
  chromadb==0.5.20
15
- langchain-text-splitters==0.3.5
16
  sentence-transformers==3.4.1
17
  python-pptx==1.0.2
18
  groq==0.19.0
@@ -20,7 +17,6 @@ transformers
20
  pymupdf
21
  beautifulsoup4
22
  readability-lxml
23
- sqlmodel
24
  nltk
25
  tqdm
26
  pytest
 
7
  itsdangerous==2.2.0
8
  httpx==0.28.1
9
  openai>=1.0.0
 
 
10
  requests==2.32.3
11
  streamlit==1.42.2
12
  chromadb==0.5.20
 
13
  sentence-transformers==3.4.1
14
  python-pptx==1.0.2
15
  groq==0.19.0
 
17
  pymupdf
18
  beautifulsoup4
19
  readability-lxml
 
20
  nltk
21
  tqdm
22
  pytest
src/artifacts/podcast_generator.py CHANGED
@@ -6,7 +6,7 @@ from __future__ import annotations
6
  import json
7
  import os
8
  import re
9
- from datetime import datetime
10
  from pathlib import Path
11
  from typing import Any, Dict, List, Optional
12
 
@@ -166,7 +166,7 @@ class PodcastGenerator:
166
  "num_segments": len(script),
167
  "topic_focus": topic_focus,
168
  "tts_errors": self._last_tts_errors[:20],
169
- "generated_at": datetime.utcnow().isoformat(),
170
  },
171
  }
172
 
@@ -190,7 +190,7 @@ class PodcastGenerator:
190
  "llm_model": self.model,
191
  "num_segments": len(script),
192
  "topic_focus": topic_focus,
193
- "generated_at": datetime.utcnow().isoformat(),
194
  },
195
  }
196
 
@@ -206,7 +206,7 @@ class PodcastGenerator:
206
  "llm_model": self.model,
207
  "num_segments": len(script),
208
  "topic_focus": topic_focus,
209
- "generated_at": datetime.utcnow().isoformat(),
210
  },
211
  }
212
 
@@ -455,7 +455,8 @@ IMPORTANT:
455
  ) -> List[str]:
456
  """Synthesize each script segment to audio."""
457
 
458
- audio_dir = Path(f"data/users/{user_id}/notebooks/{notebook_id}/artifacts/podcasts")
 
459
  audio_dir.mkdir(parents=True, exist_ok=True)
460
 
461
  voice_maps: Dict[str, Dict[str, str]] = {
@@ -532,8 +533,9 @@ IMPORTANT:
532
  print(f" ⚠️ Error processing segment {i}: {e}")
533
  continue
534
 
535
- output_dir = Path(f"data/users/{user_id}/notebooks/{notebook_id}/artifacts/podcasts")
536
- timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
 
537
  final_path = str(output_dir / f"podcast_{timestamp}.mp3")
538
 
539
  combined.export(final_path, format="mp3")
@@ -550,12 +552,11 @@ IMPORTANT:
550
  notebook_id: str,
551
  ) -> str:
552
  """Save podcast transcript Markdown to file."""
553
- transcript_dir = Path(
554
- f"data/users/{user_id}/notebooks/{notebook_id}/artifacts/podcasts"
555
- )
556
  transcript_dir.mkdir(parents=True, exist_ok=True)
557
 
558
- timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
559
  filename = f"transcript_{timestamp}.md"
560
  filepath = transcript_dir / filename
561
 
 
6
  import json
7
  import os
8
  import re
9
+ from datetime import datetime, timezone
10
  from pathlib import Path
11
  from typing import Any, Dict, List, Optional
12
 
 
166
  "num_segments": len(script),
167
  "topic_focus": topic_focus,
168
  "tts_errors": self._last_tts_errors[:20],
169
+ "generated_at": datetime.now(timezone.utc).isoformat(),
170
  },
171
  }
172
 
 
190
  "llm_model": self.model,
191
  "num_segments": len(script),
192
  "topic_focus": topic_focus,
193
+ "generated_at": datetime.now(timezone.utc).isoformat(),
194
  },
195
  }
196
 
 
206
  "llm_model": self.model,
207
  "num_segments": len(script),
208
  "topic_focus": topic_focus,
209
+ "generated_at": datetime.now(timezone.utc).isoformat(),
210
  },
211
  }
212
 
 
455
  ) -> List[str]:
456
  """Synthesize each script segment to audio."""
457
 
458
+ storage_base = os.getenv("STORAGE_BASE_DIR", "data")
459
+ audio_dir = Path(storage_base) / "users" / user_id / "notebooks" / notebook_id / "artifacts" / "podcasts"
460
  audio_dir.mkdir(parents=True, exist_ok=True)
461
 
462
  voice_maps: Dict[str, Dict[str, str]] = {
 
533
  print(f" ⚠️ Error processing segment {i}: {e}")
534
  continue
535
 
536
+ storage_base = os.getenv("STORAGE_BASE_DIR", "data")
537
+ output_dir = Path(storage_base) / "users" / user_id / "notebooks" / notebook_id / "artifacts" / "podcasts"
538
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
539
  final_path = str(output_dir / f"podcast_{timestamp}.mp3")
540
 
541
  combined.export(final_path, format="mp3")
 
552
  notebook_id: str,
553
  ) -> str:
554
  """Save podcast transcript Markdown to file."""
555
+ storage_base = os.getenv("STORAGE_BASE_DIR", "data")
556
+ transcript_dir = Path(storage_base) / "users" / user_id / "notebooks" / notebook_id / "artifacts" / "podcasts"
 
557
  transcript_dir.mkdir(parents=True, exist_ok=True)
558
 
559
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
560
  filename = f"transcript_{timestamp}.md"
561
  filepath = transcript_dir / filename
562
 
src/artifacts/quiz_generator.py CHANGED
@@ -6,7 +6,7 @@ from __future__ import annotations
6
  import json
7
  import os
8
  import re
9
- from datetime import datetime
10
  from pathlib import Path
11
  from typing import Any, Dict, List, Optional
12
 
@@ -135,7 +135,7 @@ class QuizGenerator:
135
  "topic_focus": topic_focus,
136
  "llm_provider": self.llm_provider,
137
  "llm_model": self.model,
138
- "generated_at": datetime.utcnow().isoformat(),
139
  },
140
  }
141
 
@@ -149,7 +149,7 @@ class QuizGenerator:
149
  "topic_focus": topic_focus,
150
  "llm_provider": self.llm_provider,
151
  "llm_model": self.model,
152
- "generated_at": datetime.utcnow().isoformat(),
153
  },
154
  }
155
 
@@ -466,10 +466,11 @@ Requirements:
466
 
467
  def save_quiz(self, quiz_markdown: str, user_id: str, notebook_id: str) -> str:
468
  """Save generated quiz Markdown to file."""
469
- quiz_dir = Path(f"data/users/{user_id}/notebooks/{notebook_id}/artifacts/quizzes")
 
470
  quiz_dir.mkdir(parents=True, exist_ok=True)
471
 
472
- timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
473
  filename = f"quiz_{timestamp}.md"
474
  filepath = quiz_dir / filename
475
 
 
6
  import json
7
  import os
8
  import re
9
+ from datetime import datetime, timezone
10
  from pathlib import Path
11
  from typing import Any, Dict, List, Optional
12
 
 
135
  "topic_focus": topic_focus,
136
  "llm_provider": self.llm_provider,
137
  "llm_model": self.model,
138
+ "generated_at": datetime.now(timezone.utc).isoformat(),
139
  },
140
  }
141
 
 
149
  "topic_focus": topic_focus,
150
  "llm_provider": self.llm_provider,
151
  "llm_model": self.model,
152
+ "generated_at": datetime.now(timezone.utc).isoformat(),
153
  },
154
  }
155
 
 
466
 
467
  def save_quiz(self, quiz_markdown: str, user_id: str, notebook_id: str) -> str:
468
  """Save generated quiz Markdown to file."""
469
+ storage_base = os.getenv("STORAGE_BASE_DIR", "data")
470
+ quiz_dir = Path(storage_base) / "users" / user_id / "notebooks" / notebook_id / "artifacts" / "quizzes"
471
  quiz_dir.mkdir(parents=True, exist_ok=True)
472
 
473
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
474
  filename = f"quiz_{timestamp}.md"
475
  filepath = quiz_dir / filename
476
 
src/artifacts/report_generator.py CHANGED
@@ -4,7 +4,7 @@ Report generator using RAG context from ingested notebook content.
4
  from __future__ import annotations
5
 
6
  import os
7
- from datetime import datetime
8
  from pathlib import Path
9
  from typing import Optional
10
 
@@ -205,10 +205,11 @@ Requirements:
205
  return str(body.get("response", "")).strip()
206
 
207
  def save_report(self, markdown_text: str, user_id: str, notebook_id: str) -> str:
208
- report_dir = Path(f"data/users/{user_id}/notebooks/{notebook_id}/artifacts/reports")
 
209
  report_dir.mkdir(parents=True, exist_ok=True)
210
 
211
- timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
212
  path = report_dir / f"report_{timestamp}.md"
213
  path.write_text(markdown_text, encoding="utf-8")
214
  return str(path)
 
4
  from __future__ import annotations
5
 
6
  import os
7
+ from datetime import datetime, timezone
8
  from pathlib import Path
9
  from typing import Optional
10
 
 
205
  return str(body.get("response", "")).strip()
206
 
207
  def save_report(self, markdown_text: str, user_id: str, notebook_id: str) -> str:
208
+ storage_base = os.getenv("STORAGE_BASE_DIR", "data")
209
+ report_dir = Path(storage_base) / "users" / user_id / "notebooks" / notebook_id / "artifacts" / "reports"
210
  report_dir.mkdir(parents=True, exist_ok=True)
211
 
212
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
213
  path = report_dir / f"report_{timestamp}.md"
214
  path.write_text(markdown_text, encoding="utf-8")
215
  return str(path)
src/ingestion/service.py CHANGED
@@ -22,6 +22,18 @@ def _coerce_user_notebook_ids(owner_user_id: int, notebook_id: int) -> tuple[str
22
  return str(owner_user_id), str(notebook_id)
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  def _extract_source_text(source: Source) -> str:
26
  if source.type == "url" and source.url:
27
  return extract_text_from_url(source.url).get("text", "")
@@ -97,10 +109,7 @@ def query_notebook_chunks(
97
  query_text=query_text,
98
  top_k=top_k,
99
  source_id=(str(source_id) if source_id is not None else None),
100
- query_embedding=EmbeddingAdapter(
101
- model_name=os.getenv("EMBEDDING_MODEL", "all-MiniLM-L6-v2"),
102
- provider=os.getenv("EMBEDDING_PROVIDER", "local"),
103
- ).embed_texts([query_text], batch_size=1)[0],
104
  )
105
  return [
106
  {
 
22
  return str(owner_user_id), str(notebook_id)
23
 
24
 
25
+ _embedding_adapter_cache: dict[tuple[str, str], EmbeddingAdapter] = {}
26
+
27
+
28
+ def _get_embedding_adapter() -> EmbeddingAdapter:
29
+ model = os.getenv("EMBEDDING_MODEL", "all-MiniLM-L6-v2")
30
+ provider = os.getenv("EMBEDDING_PROVIDER", "local")
31
+ key = (model, provider)
32
+ if key not in _embedding_adapter_cache:
33
+ _embedding_adapter_cache[key] = EmbeddingAdapter(model_name=model, provider=provider)
34
+ return _embedding_adapter_cache[key]
35
+
36
+
37
  def _extract_source_text(source: Source) -> str:
38
  if source.type == "url" and source.url:
39
  return extract_text_from_url(source.url).get("text", "")
 
109
  query_text=query_text,
110
  top_k=top_k,
111
  source_id=(str(source_id) if source_id is not None else None),
112
+ query_embedding=_get_embedding_adapter().embed_texts([query_text], batch_size=1)[0],
 
 
 
113
  )
114
  return [
115
  {
utils/llm_client.py CHANGED
@@ -7,24 +7,23 @@ from groq import Groq
7
 
8
  load_dotenv()
9
 
10
- _GROQ_API_KEY = os.getenv("GROQ_API_KEY")
11
- _GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant")
12
-
13
 
14
  class LLMConfigError(RuntimeError):
15
  pass
16
 
17
 
18
  def _get_client() -> Groq:
19
- if not _GROQ_API_KEY:
 
20
  raise LLMConfigError("GROQ_API_KEY is not set.")
21
- return Groq(api_key=_GROQ_API_KEY)
22
 
23
 
24
  def generate_chat_completion(system_prompt: str, user_prompt: str) -> str:
25
  client = _get_client()
 
26
  response = client.chat.completions.create(
27
- model=_GROQ_MODEL,
28
  messages=[
29
  {"role": "system", "content": system_prompt},
30
  {"role": "user", "content": user_prompt},
 
7
 
8
  load_dotenv()
9
 
 
 
 
10
 
11
  class LLMConfigError(RuntimeError):
12
  pass
13
 
14
 
15
  def _get_client() -> Groq:
16
+ api_key = os.getenv("GROQ_API_KEY")
17
+ if not api_key:
18
  raise LLMConfigError("GROQ_API_KEY is not set.")
19
+ return Groq(api_key=api_key)
20
 
21
 
22
  def generate_chat_completion(system_prompt: str, user_prompt: str) -> str:
23
  client = _get_client()
24
+ model = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant")
25
  response = client.chat.completions.create(
26
+ model=model,
27
  messages=[
28
  {"role": "system", "content": system_prompt},
29
  {"role": "user", "content": user_prompt},