Thanh Quang commited on
Commit
28a6ae0
·
1 Parent(s): 8d3ea1c

Harden destructive actions and generation recovery

Browse files
src/ui/callbacks.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  import json
4
 
5
  import gradio as gr
@@ -49,11 +50,15 @@ def ask_question(
49
  return "Please enter a question.", ""
50
  page_num = None if page == "All pages" else int(page)
51
  set_runtime_gemini_api_key(gemini_key)
52
- result = answer(
53
- question.strip(),
54
- k=int(k),
55
- filters=build_filters(selected_docs, page_num),
56
- )
 
 
 
 
57
  return (
58
  str(export(result, fmt="md")),
59
  json.dumps(result.model_dump(), ensure_ascii=False, indent=2),
@@ -67,12 +72,15 @@ def ask_chat(
67
  selected_docs: list[str],
68
  page: str,
69
  gemini_key: str,
 
70
  ) -> tuple[list[gr.ChatMessage], str, str]:
71
  user_text = (message or "").strip()
72
  turns = _normalize_chat_history(history)
73
  if not user_text:
74
  return turns, "", ""
 
75
  answer_md, raw = ask_question(user_text, k, selected_docs, page, gemini_key)
 
76
  turns.append(gr.ChatMessage(role="user", content=user_text))
77
  turns.append(gr.ChatMessage(role="assistant", content=answer_md))
78
  return turns, raw, ""
@@ -222,6 +230,41 @@ def delete_selected_docs(
222
  return filenames, f"Deleted {len(selected)} document(s)."
223
 
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  def clear_chat() -> tuple[list[list[str]], str]:
226
  """Clear the chat history."""
227
  return [], ""
@@ -234,9 +277,22 @@ def _empty_result_error() -> gr.Error:
234
 
235
 
236
  def _generation_error(error: RuntimeError) -> gr.Error:
 
 
 
 
237
  if "Missing Gemini API key" in str(error):
238
- return gr.Error(
239
  "Gemini API key is missing. Open the Gemini API key section, enter a key, "
240
  "or set GEMINI_API_KEY before generating."
241
  )
242
- return _empty_result_error()
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import html
4
  import json
5
 
6
  import gradio as gr
 
50
  return "Please enter a question.", ""
51
  page_num = None if page == "All pages" else int(page)
52
  set_runtime_gemini_api_key(gemini_key)
53
+ try:
54
+ result = answer(
55
+ question.strip(),
56
+ k=int(k),
57
+ filters=build_filters(selected_docs, page_num),
58
+ )
59
+ except RuntimeError as error:
60
+ message = _generation_message(error)
61
+ return message, json.dumps({"error": message}, ensure_ascii=False, indent=2)
62
  return (
63
  str(export(result, fmt="md")),
64
  json.dumps(result.model_dump(), ensure_ascii=False, indent=2),
 
72
  selected_docs: list[str],
73
  page: str,
74
  gemini_key: str,
75
+ progress: gr.Progress = PROGRESS,
76
  ) -> tuple[list[gr.ChatMessage], str, str]:
77
  user_text = (message or "").strip()
78
  turns = _normalize_chat_history(history)
79
  if not user_text:
80
  return turns, "", ""
81
+ progress(0.2, desc="Retrieving context…")
82
  answer_md, raw = ask_question(user_text, k, selected_docs, page, gemini_key)
83
+ progress(0.9, desc="Formatting answer…")
84
  turns.append(gr.ChatMessage(role="user", content=user_text))
85
  turns.append(gr.ChatMessage(role="assistant", content=answer_md))
86
  return turns, raw, ""
 
230
  return filenames, f"Deleted {len(selected)} document(s)."
231
 
232
 
233
+ def prepare_delete(
234
+ selected: list[str],
235
+ ) -> tuple[str, dict[str, bool], dict[str, bool], dict[str, bool], dict[str, bool]]:
236
+ if not selected:
237
+ raise gr.Error("Select at least one document before deleting.")
238
+ count = len(selected)
239
+ names = ", ".join(html.escape(str(name), quote=False) for name in selected[:3])
240
+ if count > 3:
241
+ names += f" and {count - 3} more"
242
+ prompt = (
243
+ '<div class="delete-confirmation-message">'
244
+ f"Delete <strong>{names}</strong>? This removes the indexed vectors."
245
+ " The original PDF remains available for re-indexing.</div>"
246
+ )
247
+ return (
248
+ prompt,
249
+ gr.update(visible=True),
250
+ gr.update(visible=True),
251
+ gr.update(visible=False),
252
+ gr.update(visible=True),
253
+ )
254
+
255
+
256
+ def reset_delete_confirmation() -> tuple[
257
+ str, dict[str, bool], dict[str, bool], dict[str, bool], dict[str, bool]
258
+ ]:
259
+ return (
260
+ "",
261
+ gr.update(visible=False),
262
+ gr.update(visible=False),
263
+ gr.update(visible=True),
264
+ gr.update(visible=False),
265
+ )
266
+
267
+
268
  def clear_chat() -> tuple[list[list[str]], str]:
269
  """Clear the chat history."""
270
  return [], ""
 
277
 
278
 
279
  def _generation_error(error: RuntimeError) -> gr.Error:
280
+ return gr.Error(_generation_message(error))
281
+
282
+
283
+ def _generation_message(error: RuntimeError) -> str:
284
  if "Missing Gemini API key" in str(error):
285
+ return (
286
  "Gemini API key is missing. Open the Gemini API key section, enter a key, "
287
  "or set GEMINI_API_KEY before generating."
288
  )
289
+ error_text = str(error).lower()
290
+ if "401" in error_text or "403" in error_text or "unauthorized" in error_text:
291
+ return "Gemini rejected the API key. Check the key and try again."
292
+ if "429" in error_text or "rate limit" in error_text:
293
+ return "Gemini rate limit reached. Wait briefly, then click Generate or Send again."
294
+ if "timeout" in error_text or "timed out" in error_text:
295
+ return "Gemini timed out while generating. Check your connection and try again."
296
+ if "No relevant content" in str(error):
297
+ return "No relevant content found. Upload documents, select a scope, and try again."
298
+ return "Generation failed. Check your connection and try Generate or Send again."
src/ui/layout.py CHANGED
@@ -9,6 +9,8 @@ from src.ui.callbacks import (
9
  delete_selected_docs,
10
  generate_flashcard_set_interactive,
11
  generate_quiz_set_interactive,
 
 
12
  summarize_documents,
13
  )
14
  from src.ui.content import (
@@ -119,6 +121,20 @@ def build_demo() -> gr.Blocks:
119
  variant="stop",
120
  elem_classes="library-delete-btn",
121
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  doc_summary = gr.HTML(EMPTY_LIBRARY_HTML, elem_classes="doc-summary")
123
  docs = gr.CheckboxGroup(
124
  label="Documents",
@@ -278,6 +294,18 @@ def build_demo() -> gr.Blocks:
278
  api_name="upload_pdf",
279
  )
280
  delete_btn.click(
 
 
 
 
 
 
 
 
 
 
 
 
281
  fn=delete_selected_docs,
282
  inputs=[docs],
283
  outputs=[docs, doc_summary],
@@ -286,6 +314,27 @@ def build_demo() -> gr.Blocks:
286
  fn=refresh_docs,
287
  inputs=[],
288
  outputs=[docs, doc_map_state, page, doc_summary, doc_list_md],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  )
290
  clear_chat_btn.click(
291
  fn=clear_chat,
 
9
  delete_selected_docs,
10
  generate_flashcard_set_interactive,
11
  generate_quiz_set_interactive,
12
+ prepare_delete,
13
+ reset_delete_confirmation,
14
  summarize_documents,
15
  )
16
  from src.ui.content import (
 
121
  variant="stop",
122
  elem_classes="library-delete-btn",
123
  )
124
+ delete_prompt = gr.HTML(visible=False, elem_classes="delete-confirmation")
125
+ with gr.Row(
126
+ visible=False,
127
+ elem_classes="delete-confirmation-actions",
128
+ ) as delete_confirmation_actions:
129
+ confirm_delete_btn = gr.Button(
130
+ "Confirm delete",
131
+ variant="stop",
132
+ elem_classes="library-delete-confirm-btn",
133
+ )
134
+ cancel_delete_btn = gr.Button(
135
+ "Cancel",
136
+ elem_classes="library-delete-cancel-btn",
137
+ )
138
  doc_summary = gr.HTML(EMPTY_LIBRARY_HTML, elem_classes="doc-summary")
139
  docs = gr.CheckboxGroup(
140
  label="Documents",
 
294
  api_name="upload_pdf",
295
  )
296
  delete_btn.click(
297
+ fn=prepare_delete,
298
+ inputs=[docs],
299
+ outputs=[
300
+ delete_prompt,
301
+ confirm_delete_btn,
302
+ cancel_delete_btn,
303
+ delete_btn,
304
+ delete_confirmation_actions,
305
+ ],
306
+ api_name="prepare_delete",
307
+ )
308
+ confirm_delete_btn.click(
309
  fn=delete_selected_docs,
310
  inputs=[docs],
311
  outputs=[docs, doc_summary],
 
314
  fn=refresh_docs,
315
  inputs=[],
316
  outputs=[docs, doc_map_state, page, doc_summary, doc_list_md],
317
+ ).then(
318
+ fn=reset_delete_confirmation,
319
+ inputs=[],
320
+ outputs=[
321
+ delete_prompt,
322
+ confirm_delete_btn,
323
+ cancel_delete_btn,
324
+ delete_btn,
325
+ delete_confirmation_actions,
326
+ ],
327
+ )
328
+ cancel_delete_btn.click(
329
+ fn=reset_delete_confirmation,
330
+ inputs=[],
331
+ outputs=[
332
+ delete_prompt,
333
+ confirm_delete_btn,
334
+ cancel_delete_btn,
335
+ delete_btn,
336
+ delete_confirmation_actions,
337
+ ],
338
  )
339
  clear_chat_btn.click(
340
  fn=clear_chat,
static/style.css CHANGED
@@ -2164,6 +2164,33 @@ body {
2164
  color: #f09ba2 !important;
2165
  }
2166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2167
  .library-document-picker,
2168
  .library-page-picker {
2169
  padding: var(--space-4) !important;
 
2164
  color: #f09ba2 !important;
2165
  }
2166
 
2167
+ .delete-confirmation {
2168
+ margin-top: var(--space-3) !important;
2169
+ padding: var(--space-3) var(--space-4) !important;
2170
+ border: 1px solid rgba(224, 98, 108, 0.28) !important;
2171
+ border-radius: var(--radius-card) !important;
2172
+ color: var(--text-secondary) !important;
2173
+ background: rgba(224, 98, 108, 0.06) !important;
2174
+ font-size: 0.8125rem !important;
2175
+ line-height: 1.5 !important;
2176
+ }
2177
+
2178
+ .delete-confirmation-actions {
2179
+ gap: var(--space-2) !important;
2180
+ margin-top: var(--space-2) !important;
2181
+ }
2182
+
2183
+ .delete-confirmation-actions > button {
2184
+ flex: 1 1 0 !important;
2185
+ min-width: 0 !important;
2186
+ }
2187
+
2188
+ .gradio-container .library-delete-confirm-btn {
2189
+ border-color: rgba(224, 98, 108, 0.45) !important;
2190
+ color: #f09ba2 !important;
2191
+ background: rgba(224, 98, 108, 0.1) !important;
2192
+ }
2193
+
2194
  .library-document-picker,
2195
  .library-page-picker {
2196
  padding: var(--space-4) !important;
tests/test_ui_contracts.py CHANGED
@@ -61,8 +61,8 @@ def test_demo_keeps_learning_event_contract() -> None:
61
  if dependency["backend_fn"]
62
  }
63
 
64
- assert len(config["components"]) == 97
65
- assert len(config["dependencies"]) == 14
66
  assert contracts["refresh_documents"] == (0, 5)
67
  assert contracts["pages_for_selection"] == (2, 1)
68
  assert contracts["upload_pdf"] == (1, 6)
@@ -73,6 +73,9 @@ def test_demo_keeps_learning_event_contract() -> None:
73
  assert contracts["generate_flashcard_set"] == (6, 3)
74
  assert contracts["clear_chat"] == (0, 1)
75
  assert contracts["delete_selected_docs"] == (1, 2)
 
 
 
76
  assert contracts["lambda"] == (1, 1)
77
  assert contracts["lambda_1"] == (1, 1)
78
  assert contracts["lambda_2"] == (1, 1)
@@ -134,6 +137,18 @@ def test_missing_gemini_key_error_names_recovery_action() -> None:
134
  assert "GEMINI_API_KEY" in str(error)
135
 
136
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  def test_upload_dropzone_keeps_internal_icon_buttons_scoped() -> None:
138
  assert ".upload-dropzone button {" not in CSS
139
  assert ".upload-dropzone > button:not(.icon-button)" in CSS
@@ -448,7 +463,7 @@ def test_library_refresh_keeps_page_scope_disabled(
448
  def test_refactor_preserves_stylesheet() -> None:
449
  digest = hashlib.sha256(CSS.encode()).hexdigest()
450
 
451
- assert digest == "d0b59f8197f105ebbad433ba843ac55569e9b1a183e1c9a1d1e1812b0636f4a7"
452
 
453
 
454
  def test_mobile_heading_keeps_word_boundary_when_break_is_hidden() -> None:
 
61
  if dependency["backend_fn"]
62
  }
63
 
64
+ assert len(config["components"]) == 101
65
+ assert len(config["dependencies"]) == 17
66
  assert contracts["refresh_documents"] == (0, 5)
67
  assert contracts["pages_for_selection"] == (2, 1)
68
  assert contracts["upload_pdf"] == (1, 6)
 
73
  assert contracts["generate_flashcard_set"] == (6, 3)
74
  assert contracts["clear_chat"] == (0, 1)
75
  assert contracts["delete_selected_docs"] == (1, 2)
76
+ assert contracts["prepare_delete"] == (1, 5)
77
+ assert contracts["reset_delete_confirmation"] == (0, 5)
78
+ assert contracts["reset_delete_confirmation_1"] == (0, 5)
79
  assert contracts["lambda"] == (1, 1)
80
  assert contracts["lambda_1"] == (1, 1)
81
  assert contracts["lambda_2"] == (1, 1)
 
137
  assert "GEMINI_API_KEY" in str(error)
138
 
139
 
140
+ def test_delete_confirmation_escapes_names_and_preserves_restore_path() -> None:
141
+ from src.ui.callbacks import prepare_delete
142
+
143
+ prompt, confirm, cancel, delete, actions = prepare_delete(["<notes>.pdf"])
144
+
145
+ assert "&lt;notes&gt;.pdf" in prompt
146
+ assert confirm["visible"] is True
147
+ assert cancel["visible"] is True
148
+ assert delete["visible"] is False
149
+ assert actions["visible"] is True
150
+
151
+
152
  def test_upload_dropzone_keeps_internal_icon_buttons_scoped() -> None:
153
  assert ".upload-dropzone button {" not in CSS
154
  assert ".upload-dropzone > button:not(.icon-button)" in CSS
 
463
  def test_refactor_preserves_stylesheet() -> None:
464
  digest = hashlib.sha256(CSS.encode()).hexdigest()
465
 
466
+ assert digest == "55e1a75fdea3713501f288e6facea4562bb231bc70ba9691f11a755d7a015ba9"
467
 
468
 
469
  def test_mobile_heading_keeps_word_boundary_when_break_is_hidden() -> None: