Commit ·
bc2d97e
1
Parent(s): 16fa4e7
Add support for multiple PDF uploads and enhanced citation handling
Browse files- Implemented functionality to upload multiple PDF files and process them concurrently.
- Introduced new helper functions for reading uploaded PDFs and rendering citations with source text.
- Enhanced Markdown export to include detailed citation blocks.
- Updated UI components to reflect changes in file upload capabilities and citation display.
- Improved progress tracking during document processing.
- app.py +85 -14
- src/export.py +54 -8
- src/rag.py +1 -0
- src/schemas.py +1 -0
- static/style.css +263 -69
app.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import base64
|
|
|
|
| 4 |
import json
|
| 5 |
from pathlib import Path
|
| 6 |
from typing import Any
|
|
@@ -47,6 +48,26 @@ def _img_b64(path: str) -> str:
|
|
| 47 |
def _status_html(message: str) -> str:
|
| 48 |
return f'<div class="status-bar">{message}</div>'
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
def _read_uploaded_pdf(file_obj: object) -> tuple[bytes, str]:
|
| 51 |
"""Normalize Gradio file payload into (bytes, filename).
|
| 52 |
|
|
@@ -76,6 +97,14 @@ def _read_uploaded_pdf(file_obj: object) -> tuple[bytes, str]:
|
|
| 76 |
|
| 77 |
raise TypeError(f"Unsupported uploaded file type: {type(file_obj).__name__}")
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
def _filters(filenames: list[str] | None, page: int | None) -> dict[str, object] | None:
|
| 81 |
payload: dict[str, object] = {}
|
|
@@ -119,7 +148,8 @@ def _pages_for_selection(doc_map: dict[str, Any], selected: list[str]) -> gr.Dro
|
|
| 119 |
def _upload_pdf(
|
| 120 |
file: object | None,
|
| 121 |
) -> tuple[str, object, dict[str, Any], object, str, str]:
|
| 122 |
-
|
|
|
|
| 123 |
choices, doc_map, page_dropdown, summary, filenames_text = _refresh_docs()
|
| 124 |
return (
|
| 125 |
_status_html("⚠️ Vui lòng chọn file PDF."),
|
|
@@ -129,9 +159,31 @@ def _upload_pdf(
|
|
| 129 |
summary,
|
| 130 |
filenames_text,
|
| 131 |
)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
choices, doc_map, page_dropdown, summary, filenames_text = _refresh_docs()
|
| 136 |
return message, choices, doc_map, page_dropdown, summary, filenames_text
|
| 137 |
|
|
@@ -142,13 +194,23 @@ def _ask(question: str, k: int, selected_docs: list[str], page: str, gemini_key:
|
|
| 142 |
page_num = None if page == "(Tất cả trang)" else int(page)
|
| 143 |
set_runtime_gemini_api_key(gemini_key)
|
| 144 |
res = answer(question.strip(), k=int(k), filters=_filters(selected_docs, page_num))
|
| 145 |
-
return res
|
| 146 |
|
| 147 |
|
| 148 |
-
def _summarize(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
page_num = None if page == "(Tất cả trang)" else int(page)
|
| 150 |
set_runtime_gemini_api_key(gemini_key)
|
|
|
|
|
|
|
| 151 |
res = summarize(query=query.strip() or None, filters=_filters(selected_docs, page_num), k=int(k))
|
|
|
|
| 152 |
return export(res, fmt="md"), res.model_dump_json(indent=2)
|
| 153 |
|
| 154 |
|
|
@@ -159,15 +221,19 @@ def _quiz(
|
|
| 159 |
selected_docs: list[str],
|
| 160 |
page: str,
|
| 161 |
gemini_key: str,
|
|
|
|
| 162 |
) -> tuple[str, str]:
|
| 163 |
page_num = None if page == "(Tất cả trang)" else int(page)
|
| 164 |
set_runtime_gemini_api_key(gemini_key)
|
|
|
|
|
|
|
| 165 |
res = generate_quiz(
|
| 166 |
query=query.strip() or None,
|
| 167 |
count=int(count),
|
| 168 |
filters=_filters(selected_docs, page_num),
|
| 169 |
k=int(k),
|
| 170 |
)
|
|
|
|
| 171 |
return export(res, fmt="md"), res.model_dump_json(indent=2)
|
| 172 |
|
| 173 |
|
|
@@ -178,15 +244,19 @@ def _flashcards(
|
|
| 178 |
selected_docs: list[str],
|
| 179 |
page: str,
|
| 180 |
gemini_key: str,
|
|
|
|
| 181 |
) -> tuple[str, str]:
|
| 182 |
page_num = None if page == "(Tất cả trang)" else int(page)
|
| 183 |
set_runtime_gemini_api_key(gemini_key)
|
|
|
|
|
|
|
| 184 |
res = generate_flashcards(
|
| 185 |
query=query.strip() or None,
|
| 186 |
count=int(count),
|
| 187 |
filters=_filters(selected_docs, page_num),
|
| 188 |
k=int(k),
|
| 189 |
)
|
|
|
|
| 190 |
return export(res, fmt="md"), res.model_dump_json(indent=2)
|
| 191 |
|
| 192 |
|
|
@@ -208,13 +278,13 @@ _theme = gr.themes.Base().set(
|
|
| 208 |
input_background_fill="#ffffff",
|
| 209 |
)
|
| 210 |
|
| 211 |
-
with gr.Blocks(title="
|
| 212 |
with gr.Row(elem_classes="header-row"):
|
| 213 |
logo_b64 = _img_b64("static/aivn_logo.png")
|
| 214 |
gr.HTML(f'<img src="data:image/png;base64,{logo_b64}" alt="AIVN">')
|
| 215 |
gr.HTML(
|
| 216 |
'<div class="header-meta">'
|
| 217 |
-
'<p class="header-title">📚
|
| 218 |
'<p class="header-sub">AIO2025 — Hỏi đáp · Tóm tắt · Quiz · Flashcards có trích dẫn nguồn</p>'
|
| 219 |
'</div>'
|
| 220 |
)
|
|
@@ -229,7 +299,7 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
|
|
| 229 |
upload = gr.File(
|
| 230 |
label="Chọn PDF",
|
| 231 |
file_types=[".pdf"],
|
| 232 |
-
file_count="
|
| 233 |
type="filepath",
|
| 234 |
)
|
| 235 |
upload_btn = gr.Button("Nạp & Index", elem_classes="gen-btn")
|
|
@@ -238,7 +308,8 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
|
|
| 238 |
# with gr.Group(elem_classes="control-card"):
|
| 239 |
with gr.Accordion("🔑 Gemini API Key (tuỳ chọn)", open=False):
|
| 240 |
gr.Markdown(
|
| 241 |
-
"API key
|
|
|
|
| 242 |
elem_classes="help-markdown",
|
| 243 |
)
|
| 244 |
gemini_key_input = gr.Textbox(
|
|
@@ -291,7 +362,7 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
|
|
| 291 |
)
|
| 292 |
k_ask = gr.Slider(1, 32, value=6, step=1, label="Top-k retrieval")
|
| 293 |
ask_btn = gr.Button("Trả lời", elem_classes="gen-btn")
|
| 294 |
-
ask_md =
|
| 295 |
with gr.Accordion("JSON debug", open=False):
|
| 296 |
ask_raw = gr.Code(label="", language="json")
|
| 297 |
|
|
@@ -299,7 +370,7 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
|
|
| 299 |
s_query = gr.Textbox(label="Chủ đề hướng dẫn (tuỳ chọn)", lines=1)
|
| 300 |
s_k = gr.Slider(1, 64, value=10, step=1, label="Số đoạn truy xuất (k)")
|
| 301 |
s_btn = gr.Button("Tạo tóm tắt", elem_classes="gen-btn")
|
| 302 |
-
s_md =
|
| 303 |
s_download = gr.File(label="Tải Markdown", interactive=False)
|
| 304 |
with gr.Accordion("JSON debug", open=False):
|
| 305 |
s_raw = gr.Code(label="", language="json")
|
|
@@ -309,7 +380,7 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
|
|
| 309 |
z_count = gr.Slider(1, 30, value=3, step=1, label="Số câu hỏi")
|
| 310 |
z_k = gr.Slider(1, 64, value=10, step=1, label="Số đoạn truy xuất (k)")
|
| 311 |
z_btn = gr.Button("Tạo quiz", elem_classes="gen-btn")
|
| 312 |
-
z_md =
|
| 313 |
z_download = gr.File(label="Tải Markdown", interactive=False)
|
| 314 |
with gr.Accordion("JSON debug", open=False):
|
| 315 |
z_raw = gr.Code(label="", language="json")
|
|
@@ -319,7 +390,7 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
|
|
| 319 |
f_count = gr.Slider(1, 40, value=15, step=1, label="Số thẻ")
|
| 320 |
f_k = gr.Slider(1, 64, value=16, step=1, label="Số đoạn truy xuất (k)")
|
| 321 |
f_btn = gr.Button("Tạo flashcards", elem_classes="gen-btn")
|
| 322 |
-
f_md =
|
| 323 |
f_download = gr.File(label="Tải Markdown", interactive=False)
|
| 324 |
with gr.Accordion("JSON debug", open=False):
|
| 325 |
f_raw = gr.Code(label="", language="json")
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import base64
|
| 4 |
+
import html
|
| 5 |
import json
|
| 6 |
from pathlib import Path
|
| 7 |
from typing import Any
|
|
|
|
| 48 |
def _status_html(message: str) -> str:
|
| 49 |
return f'<div class="status-bar">{message}</div>'
|
| 50 |
|
| 51 |
+
def _result_markdown() -> gr.Markdown:
|
| 52 |
+
"""Create a result Markdown that allows safe HTML when supported.
|
| 53 |
+
|
| 54 |
+
We render citations using HTML <details> blocks for expand/collapse.
|
| 55 |
+
Gradio versions differ in how they gate HTML in Markdown, so we fall back
|
| 56 |
+
gracefully when a flag is unavailable.
|
| 57 |
+
"""
|
| 58 |
+
candidates = (
|
| 59 |
+
{"sanitize_html": False},
|
| 60 |
+
{"sanitize": False},
|
| 61 |
+
{"unsafe_allow_html": True},
|
| 62 |
+
{},
|
| 63 |
+
)
|
| 64 |
+
for kwargs in candidates:
|
| 65 |
+
try:
|
| 66 |
+
return gr.Markdown(elem_classes="result-markdown", **kwargs)
|
| 67 |
+
except TypeError:
|
| 68 |
+
continue
|
| 69 |
+
return gr.Markdown(elem_classes="result-markdown")
|
| 70 |
+
|
| 71 |
def _read_uploaded_pdf(file_obj: object) -> tuple[bytes, str]:
|
| 72 |
"""Normalize Gradio file payload into (bytes, filename).
|
| 73 |
|
|
|
|
| 97 |
|
| 98 |
raise TypeError(f"Unsupported uploaded file type: {type(file_obj).__name__}")
|
| 99 |
|
| 100 |
+
def _read_uploaded_pdfs(file_obj: object) -> list[tuple[bytes, str]]:
|
| 101 |
+
"""Normalize Gradio file payload into a list of (bytes, filename)."""
|
| 102 |
+
if file_obj is None:
|
| 103 |
+
return []
|
| 104 |
+
if isinstance(file_obj, (list, tuple)):
|
| 105 |
+
return [_read_uploaded_pdf(x) for x in file_obj]
|
| 106 |
+
return [_read_uploaded_pdf(file_obj)]
|
| 107 |
+
|
| 108 |
|
| 109 |
def _filters(filenames: list[str] | None, page: int | None) -> dict[str, object] | None:
|
| 110 |
payload: dict[str, object] = {}
|
|
|
|
| 148 |
def _upload_pdf(
|
| 149 |
file: object | None,
|
| 150 |
) -> tuple[str, object, dict[str, Any], object, str, str]:
|
| 151 |
+
payloads = _read_uploaded_pdfs(file)
|
| 152 |
+
if not payloads:
|
| 153 |
choices, doc_map, page_dropdown, summary, filenames_text = _refresh_docs()
|
| 154 |
return (
|
| 155 |
_status_html("⚠️ Vui lòng chọn file PDF."),
|
|
|
|
| 159 |
summary,
|
| 160 |
filenames_text,
|
| 161 |
)
|
| 162 |
+
|
| 163 |
+
successes: list[str] = []
|
| 164 |
+
failures: list[str] = []
|
| 165 |
+
chunks_total = 0
|
| 166 |
+
for file_bytes, filename in payloads:
|
| 167 |
+
try:
|
| 168 |
+
info = save_and_ingest_pdf(file_bytes, filename)
|
| 169 |
+
except Exception as e: # noqa: BLE001
|
| 170 |
+
failures.append(f"{filename}: {e}")
|
| 171 |
+
continue
|
| 172 |
+
successes.append(str(info["filename"]))
|
| 173 |
+
chunks_total += int(info.get("chunks_indexed") or 0)
|
| 174 |
+
|
| 175 |
+
parts: list[str] = []
|
| 176 |
+
if successes:
|
| 177 |
+
parts.append(f"✅ Đã nạp {len(successes)} file · {chunks_total} đoạn")
|
| 178 |
+
if failures:
|
| 179 |
+
parts.append(f"⚠️ Lỗi **{len(failures)}** file")
|
| 180 |
+
details = ""
|
| 181 |
+
if failures:
|
| 182 |
+
items = "".join(f"<li><code>{html.escape(x, quote=False)}</code></li>" for x in failures)
|
| 183 |
+
details = f"<details><summary>Xem lỗi</summary><ul>{items}</ul></details>"
|
| 184 |
+
message_body = (" · ".join(parts) if parts else "⚠️ Không có file hợp lệ.") + details
|
| 185 |
+
message = _status_html(message_body)
|
| 186 |
+
|
| 187 |
choices, doc_map, page_dropdown, summary, filenames_text = _refresh_docs()
|
| 188 |
return message, choices, doc_map, page_dropdown, summary, filenames_text
|
| 189 |
|
|
|
|
| 194 |
page_num = None if page == "(Tất cả trang)" else int(page)
|
| 195 |
set_runtime_gemini_api_key(gemini_key)
|
| 196 |
res = answer(question.strip(), k=int(k), filters=_filters(selected_docs, page_num))
|
| 197 |
+
return export(res, fmt="md"), json.dumps(res.model_dump(), ensure_ascii=False, indent=2)
|
| 198 |
|
| 199 |
|
| 200 |
+
def _summarize(
|
| 201 |
+
query: str,
|
| 202 |
+
k: int,
|
| 203 |
+
selected_docs: list[str],
|
| 204 |
+
page: str,
|
| 205 |
+
gemini_key: str,
|
| 206 |
+
progress: gr.Progress = gr.Progress(),
|
| 207 |
+
) -> tuple[str, str]:
|
| 208 |
page_num = None if page == "(Tất cả trang)" else int(page)
|
| 209 |
set_runtime_gemini_api_key(gemini_key)
|
| 210 |
+
progress(0.0, desc="Đang chuẩn bị…")
|
| 211 |
+
progress(0.2, desc="Đang truy xuất ngữ cảnh…")
|
| 212 |
res = summarize(query=query.strip() or None, filters=_filters(selected_docs, page_num), k=int(k))
|
| 213 |
+
progress(0.85, desc="Đang định dạng kết quả…")
|
| 214 |
return export(res, fmt="md"), res.model_dump_json(indent=2)
|
| 215 |
|
| 216 |
|
|
|
|
| 221 |
selected_docs: list[str],
|
| 222 |
page: str,
|
| 223 |
gemini_key: str,
|
| 224 |
+
progress: gr.Progress = gr.Progress(),
|
| 225 |
) -> tuple[str, str]:
|
| 226 |
page_num = None if page == "(Tất cả trang)" else int(page)
|
| 227 |
set_runtime_gemini_api_key(gemini_key)
|
| 228 |
+
progress(0.0, desc="Đang chuẩn bị…")
|
| 229 |
+
progress(0.2, desc="Đang truy xuất ngữ cảnh…")
|
| 230 |
res = generate_quiz(
|
| 231 |
query=query.strip() or None,
|
| 232 |
count=int(count),
|
| 233 |
filters=_filters(selected_docs, page_num),
|
| 234 |
k=int(k),
|
| 235 |
)
|
| 236 |
+
progress(0.85, desc="Đang định dạng kết quả…")
|
| 237 |
return export(res, fmt="md"), res.model_dump_json(indent=2)
|
| 238 |
|
| 239 |
|
|
|
|
| 244 |
selected_docs: list[str],
|
| 245 |
page: str,
|
| 246 |
gemini_key: str,
|
| 247 |
+
progress: gr.Progress = gr.Progress(),
|
| 248 |
) -> tuple[str, str]:
|
| 249 |
page_num = None if page == "(Tất cả trang)" else int(page)
|
| 250 |
set_runtime_gemini_api_key(gemini_key)
|
| 251 |
+
progress(0.0, desc="Đang chuẩn bị…")
|
| 252 |
+
progress(0.2, desc="Đang truy xuất ngữ cảnh…")
|
| 253 |
res = generate_flashcards(
|
| 254 |
query=query.strip() or None,
|
| 255 |
count=int(count),
|
| 256 |
filters=_filters(selected_docs, page_num),
|
| 257 |
k=int(k),
|
| 258 |
)
|
| 259 |
+
progress(0.85, desc="Đang định dạng kết quả…")
|
| 260 |
return export(res, fmt="md"), res.model_dump_json(indent=2)
|
| 261 |
|
| 262 |
|
|
|
|
| 278 |
input_background_fill="#ffffff",
|
| 279 |
)
|
| 280 |
|
| 281 |
+
with gr.Blocks(title="Building a Simple NotebookLM", fill_width=True, fill_height=True) as demo:
|
| 282 |
with gr.Row(elem_classes="header-row"):
|
| 283 |
logo_b64 = _img_b64("static/aivn_logo.png")
|
| 284 |
gr.HTML(f'<img src="data:image/png;base64,{logo_b64}" alt="AIVN">')
|
| 285 |
gr.HTML(
|
| 286 |
'<div class="header-meta">'
|
| 287 |
+
'<p class="header-title">📚 Building a Simple NotebookLM</p>'
|
| 288 |
'<p class="header-sub">AIO2025 — Hỏi đáp · Tóm tắt · Quiz · Flashcards có trích dẫn nguồn</p>'
|
| 289 |
'</div>'
|
| 290 |
)
|
|
|
|
| 299 |
upload = gr.File(
|
| 300 |
label="Chọn PDF",
|
| 301 |
file_types=[".pdf"],
|
| 302 |
+
file_count="multiple",
|
| 303 |
type="filepath",
|
| 304 |
)
|
| 305 |
upload_btn = gr.Button("Nạp & Index", elem_classes="gen-btn")
|
|
|
|
| 308 |
# with gr.Group(elem_classes="control-card"):
|
| 309 |
with gr.Accordion("🔑 Gemini API Key (tuỳ chọn)", open=False):
|
| 310 |
gr.Markdown(
|
| 311 |
+
"Nhập API key để chạy Gemini. Lấy key tại: "
|
| 312 |
+
"[Google AI Studio](https://aistudio.google.com/app/api-keys). ",
|
| 313 |
elem_classes="help-markdown",
|
| 314 |
)
|
| 315 |
gemini_key_input = gr.Textbox(
|
|
|
|
| 362 |
)
|
| 363 |
k_ask = gr.Slider(1, 32, value=6, step=1, label="Top-k retrieval")
|
| 364 |
ask_btn = gr.Button("Trả lời", elem_classes="gen-btn")
|
| 365 |
+
ask_md = _result_markdown()
|
| 366 |
with gr.Accordion("JSON debug", open=False):
|
| 367 |
ask_raw = gr.Code(label="", language="json")
|
| 368 |
|
|
|
|
| 370 |
s_query = gr.Textbox(label="Chủ đề hướng dẫn (tuỳ chọn)", lines=1)
|
| 371 |
s_k = gr.Slider(1, 64, value=10, step=1, label="Số đoạn truy xuất (k)")
|
| 372 |
s_btn = gr.Button("Tạo tóm tắt", elem_classes="gen-btn")
|
| 373 |
+
s_md = _result_markdown()
|
| 374 |
s_download = gr.File(label="Tải Markdown", interactive=False)
|
| 375 |
with gr.Accordion("JSON debug", open=False):
|
| 376 |
s_raw = gr.Code(label="", language="json")
|
|
|
|
| 380 |
z_count = gr.Slider(1, 30, value=3, step=1, label="Số câu hỏi")
|
| 381 |
z_k = gr.Slider(1, 64, value=10, step=1, label="Số đoạn truy xuất (k)")
|
| 382 |
z_btn = gr.Button("Tạo quiz", elem_classes="gen-btn")
|
| 383 |
+
z_md = _result_markdown()
|
| 384 |
z_download = gr.File(label="Tải Markdown", interactive=False)
|
| 385 |
with gr.Accordion("JSON debug", open=False):
|
| 386 |
z_raw = gr.Code(label="", language="json")
|
|
|
|
| 390 |
f_count = gr.Slider(1, 40, value=15, step=1, label="Số thẻ")
|
| 391 |
f_k = gr.Slider(1, 64, value=16, step=1, label="Số đoạn truy xuất (k)")
|
| 392 |
f_btn = gr.Button("Tạo flashcards", elem_classes="gen-btn")
|
| 393 |
+
f_md = _result_markdown()
|
| 394 |
f_download = gr.File(label="Tải Markdown", interactive=False)
|
| 395 |
with gr.Accordion("JSON debug", open=False):
|
| 396 |
f_raw = gr.Code(label="", language="json")
|
src/export.py
CHANGED
|
@@ -2,12 +2,13 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
from typing import Literal
|
| 7 |
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
-
from src.schemas import Citation, FlashcardSet, QuizSet, Summary
|
| 11 |
|
| 12 |
ExportFormat = Literal["text", "md", "json"]
|
| 13 |
|
|
@@ -21,14 +22,59 @@ def _citation_line(c: Citation) -> str:
|
|
| 21 |
return " | ".join(parts)
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def _citations_block(citations: list[Citation]) -> str:
|
| 25 |
if not citations:
|
| 26 |
return ""
|
| 27 |
lines = ["## Sources", ""]
|
| 28 |
-
|
|
|
|
| 29 |
return "\n".join(lines) + "\n"
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
def _to_markdown(model: BaseModel) -> str:
|
| 33 |
if isinstance(model, Summary):
|
| 34 |
title = "# Summary" + (f": {model.target}" if model.target else "")
|
|
@@ -37,10 +83,10 @@ def _to_markdown(model: BaseModel) -> str:
|
|
| 37 |
lines.extend([model.summary.strip(), ""])
|
| 38 |
if model.key_points:
|
| 39 |
lines.extend(["## Key Points", "", *[f"- {kp}" for kp in model.key_points], ""])
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
return
|
| 44 |
|
| 45 |
if isinstance(model, QuizSet):
|
| 46 |
title = "# Quiz" + (f": {model.target}" if model.target else "")
|
|
@@ -61,7 +107,7 @@ def _to_markdown(model: BaseModel) -> str:
|
|
| 61 |
if item.explanation:
|
| 62 |
lines.append(f"**Explanation:** {item.explanation.strip()}")
|
| 63 |
if item.source_markers:
|
| 64 |
-
lines.
|
| 65 |
lines.append("")
|
| 66 |
|
| 67 |
c = _citations_block(model.citations)
|
|
@@ -80,7 +126,7 @@ def _to_markdown(model: BaseModel) -> str:
|
|
| 80 |
if card.hint:
|
| 81 |
lines.append(f"**Hint:** {card.hint.strip()}")
|
| 82 |
if card.source_markers:
|
| 83 |
-
lines.
|
| 84 |
lines.append("")
|
| 85 |
|
| 86 |
c = _citations_block(model.citations)
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import html
|
| 6 |
from pathlib import Path
|
| 7 |
from typing import Literal
|
| 8 |
|
| 9 |
from pydantic import BaseModel
|
| 10 |
|
| 11 |
+
from src.schemas import Citation, FlashcardSet, QuizSet, RagAnswer, Summary
|
| 12 |
|
| 13 |
ExportFormat = Literal["text", "md", "json"]
|
| 14 |
|
|
|
|
| 22 |
return " | ".join(parts)
|
| 23 |
|
| 24 |
|
| 25 |
+
def _details_block(summary: str, content: str) -> str:
|
| 26 |
+
safe_summary = html.escape(summary, quote=False)
|
| 27 |
+
safe_content = html.escape(content, quote=False)
|
| 28 |
+
return (
|
| 29 |
+
"<details>"
|
| 30 |
+
f"<summary>{safe_summary}</summary>"
|
| 31 |
+
f"<div style=\"margin:8px 0 0 0; white-space:pre-wrap;\">{safe_content}</div>"
|
| 32 |
+
"</details>"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _citation_source_text_block(c: Citation) -> str:
|
| 37 |
+
if not c.source_text:
|
| 38 |
+
return ""
|
| 39 |
+
return "\n " + _details_block("Xem đoạn nguồn", c.source_text)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _marker_details(citations: list[Citation], markers: list[str]) -> list[str]:
|
| 43 |
+
by_marker = {c.source_marker: c for c in citations}
|
| 44 |
+
lines: list[str] = []
|
| 45 |
+
for m in markers:
|
| 46 |
+
c = by_marker.get(m)
|
| 47 |
+
if c is None:
|
| 48 |
+
continue
|
| 49 |
+
summary = f"[{c.source_marker}] {c.filename} p.{c.page}"
|
| 50 |
+
if c.section:
|
| 51 |
+
summary += f" | section: {c.section}"
|
| 52 |
+
if c.chunk_id:
|
| 53 |
+
summary += f" | chunk: {c.chunk_id}"
|
| 54 |
+
if c.source_text:
|
| 55 |
+
lines.append(f"- {_details_block(summary, c.source_text)}")
|
| 56 |
+
else:
|
| 57 |
+
lines.append(f"- {summary}")
|
| 58 |
+
return lines
|
| 59 |
+
|
| 60 |
+
|
| 61 |
def _citations_block(citations: list[Citation]) -> str:
|
| 62 |
if not citations:
|
| 63 |
return ""
|
| 64 |
lines = ["## Sources", ""]
|
| 65 |
+
for c in citations:
|
| 66 |
+
lines.append(f"- {_citation_line(c)}{_citation_source_text_block(c)}")
|
| 67 |
return "\n".join(lines) + "\n"
|
| 68 |
|
| 69 |
|
| 70 |
+
def _render_with_sources(body_lines: list[str], citations: list[Citation]) -> str:
|
| 71 |
+
lines = [*body_lines, ""]
|
| 72 |
+
c = _citations_block(citations)
|
| 73 |
+
if c:
|
| 74 |
+
lines.append(c)
|
| 75 |
+
return "\n".join(lines).rstrip() + "\n"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
def _to_markdown(model: BaseModel) -> str:
|
| 79 |
if isinstance(model, Summary):
|
| 80 |
title = "# Summary" + (f": {model.target}" if model.target else "")
|
|
|
|
| 83 |
lines.extend([model.summary.strip(), ""])
|
| 84 |
if model.key_points:
|
| 85 |
lines.extend(["## Key Points", "", *[f"- {kp}" for kp in model.key_points], ""])
|
| 86 |
+
return _render_with_sources(lines, model.citations)
|
| 87 |
+
|
| 88 |
+
if isinstance(model, RagAnswer):
|
| 89 |
+
return _render_with_sources([model.answer.strip()], model.citations)
|
| 90 |
|
| 91 |
if isinstance(model, QuizSet):
|
| 92 |
title = "# Quiz" + (f": {model.target}" if model.target else "")
|
|
|
|
| 107 |
if item.explanation:
|
| 108 |
lines.append(f"**Explanation:** {item.explanation.strip()}")
|
| 109 |
if item.source_markers:
|
| 110 |
+
lines.extend(["**Sources:**", *_marker_details(model.citations, item.source_markers)])
|
| 111 |
lines.append("")
|
| 112 |
|
| 113 |
c = _citations_block(model.citations)
|
|
|
|
| 126 |
if card.hint:
|
| 127 |
lines.append(f"**Hint:** {card.hint.strip()}")
|
| 128 |
if card.source_markers:
|
| 129 |
+
lines.extend(["**Sources:**", *_marker_details(model.citations, card.source_markers)])
|
| 130 |
lines.append("")
|
| 131 |
|
| 132 |
c = _citations_block(model.citations)
|
src/rag.py
CHANGED
|
@@ -87,6 +87,7 @@ def format_citations(chunks: list[RetrievedChunk]) -> list[Citation]:
|
|
| 87 |
source_marker=f"S{i}",
|
| 88 |
filename=c.metadata.filename,
|
| 89 |
page=c.metadata.page,
|
|
|
|
| 90 |
section=c.metadata.section,
|
| 91 |
chunk_id=c.metadata.chunk_id,
|
| 92 |
)
|
|
|
|
| 87 |
source_marker=f"S{i}",
|
| 88 |
filename=c.metadata.filename,
|
| 89 |
page=c.metadata.page,
|
| 90 |
+
source_text=c.text.strip(),
|
| 91 |
section=c.metadata.section,
|
| 92 |
chunk_id=c.metadata.chunk_id,
|
| 93 |
)
|
src/schemas.py
CHANGED
|
@@ -33,6 +33,7 @@ class Citation(BaseModel):
|
|
| 33 |
source_marker: str
|
| 34 |
filename: str
|
| 35 |
page: int
|
|
|
|
| 36 |
section: str | None = None
|
| 37 |
chunk_id: str | None = None
|
| 38 |
|
|
|
|
| 33 |
source_marker: str
|
| 34 |
filename: str
|
| 35 |
page: int
|
| 36 |
+
source_text: str | None = None
|
| 37 |
section: str | None = None
|
| 38 |
chunk_id: str | None = None
|
| 39 |
|
static/style.css
CHANGED
|
@@ -1,42 +1,43 @@
|
|
| 1 |
/* ==========================================================
|
| 2 |
RAG Learning System — Design Tokens
|
| 3 |
========================================================== */
|
| 4 |
-
:root,
|
| 5 |
-
|
| 6 |
-
--c-bg
|
| 7 |
-
--c-
|
| 8 |
-
--c-surface
|
|
|
|
| 9 |
--c-surface-tint: rgba(255, 255, 255, 0.72);
|
| 10 |
|
| 11 |
-
--c-primary:
|
| 12 |
-
--c-primary-dk:
|
| 13 |
--c-primary-pale: #dde4fd;
|
| 14 |
|
| 15 |
-
--c-accent:
|
| 16 |
|
| 17 |
-
--c-text:
|
| 18 |
-
--c-text-2:
|
| 19 |
-
--c-text-muted:
|
| 20 |
|
| 21 |
/* Border needs to be darker than background for legibility */
|
| 22 |
-
--c-border:
|
| 23 |
-
--c-border-strong:#9faee0;
|
| 24 |
|
| 25 |
-
--c-note-bg:
|
| 26 |
-
--c-note-bd:
|
| 27 |
-
--c-note-txt:
|
| 28 |
|
| 29 |
-
--c-status-bg:
|
| 30 |
|
| 31 |
--r-lg: 16px;
|
| 32 |
--r-md: 12px;
|
| 33 |
-
--r-sm:
|
| 34 |
-
--r-xs:
|
| 35 |
|
| 36 |
-
--shadow-card: 0 3px 16px rgba(40,55,130,.12), 0 1px 5px rgba(40,55,130,.07);
|
| 37 |
-
--shadow-panel: 0 6px 24px rgba(40,55,130,.10), 0 2px 8px rgba(40,55,130,.06);
|
| 38 |
-
--shadow-btn:
|
| 39 |
-
--shadow-foc:
|
| 40 |
|
| 41 |
--app-max-width: 1440px;
|
| 42 |
}
|
|
@@ -45,19 +46,21 @@
|
|
| 45 |
Gradio variable bridge — all blocks transparent by default
|
| 46 |
========================================================== */
|
| 47 |
#gradio-app {
|
| 48 |
-
--background-fill-primary:
|
| 49 |
--background-fill-secondary: var(--c-bg-2);
|
| 50 |
-
--block-background-fill:
|
| 51 |
-
--block-border-color:
|
| 52 |
-
--block-border-width:
|
| 53 |
-
--input-background-fill:
|
| 54 |
}
|
| 55 |
|
| 56 |
/* ==========================================================
|
| 57 |
Page shell
|
| 58 |
========================================================== */
|
| 59 |
-
html,
|
| 60 |
-
|
|
|
|
|
|
|
| 61 |
background: linear-gradient(155deg, var(--c-bg) 0%, var(--c-bg-2) 100%) !important;
|
| 62 |
color: var(--c-text);
|
| 63 |
min-height: 100vh;
|
|
@@ -131,9 +134,15 @@ html, body {
|
|
| 131 |
|
| 132 |
.info-card-title,
|
| 133 |
.info-card-list,
|
| 134 |
-
.info-card-list li {
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
.info-card-title {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
.info-card-list {
|
| 139 |
margin: 0;
|
|
@@ -142,7 +151,9 @@ html, body {
|
|
| 142 |
line-height: 1.65;
|
| 143 |
}
|
| 144 |
|
| 145 |
-
.info-card-list li
|
|
|
|
|
|
|
| 146 |
|
| 147 |
/* ==========================================================
|
| 148 |
2-Column main layout — EXPLICIT flex, do not rely solely on Gradio
|
|
@@ -196,7 +207,9 @@ html, body {
|
|
| 196 |
}
|
| 197 |
|
| 198 |
/* accent top stripe */
|
| 199 |
-
.control-card {
|
|
|
|
|
|
|
| 200 |
|
| 201 |
/* strip nested .block inside cards — theme transparent already handles most,
|
| 202 |
but belt-and-suspenders for sub-blocks that may carry inline styles */
|
|
@@ -210,7 +223,9 @@ html, body {
|
|
| 210 |
/* ==========================================================
|
| 211 |
Typography
|
| 212 |
========================================================== */
|
| 213 |
-
label,
|
|
|
|
|
|
|
| 214 |
color: var(--c-text-2) !important;
|
| 215 |
font-size: 0.93rem !important;
|
| 216 |
font-weight: 600 !important;
|
|
@@ -229,14 +244,17 @@ label, .gr-label, .block-title {
|
|
| 229 |
/* standalone markdown outside cards (doc_list_md etc.) */
|
| 230 |
.prose p,
|
| 231 |
.prose li,
|
| 232 |
-
.prose h1,
|
|
|
|
|
|
|
| 233 |
color: var(--c-text) !important;
|
| 234 |
}
|
| 235 |
|
| 236 |
/* ==========================================================
|
| 237 |
Inputs & textareas
|
| 238 |
========================================================== */
|
| 239 |
-
textarea,
|
|
|
|
| 240 |
background: var(--c-surface) !important;
|
| 241 |
color: var(--c-text) !important;
|
| 242 |
font-weight: 500 !important;
|
|
@@ -261,13 +279,17 @@ select:focus {
|
|
| 261 |
outline: none !important;
|
| 262 |
}
|
| 263 |
|
| 264 |
-
textarea:focus,
|
|
|
|
| 265 |
border-color: var(--c-primary) !important;
|
| 266 |
box-shadow: var(--shadow-foc) !important;
|
| 267 |
outline: none !important;
|
| 268 |
}
|
| 269 |
|
| 270 |
-
::placeholder {
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
/* ==========================================================
|
| 273 |
Gradio form wrappers — remove default gray panels
|
|
@@ -296,6 +318,73 @@ textarea:focus, input:focus {
|
|
| 296 |
padding: 0 8px !important;
|
| 297 |
}
|
| 298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
/* Make option rows readable and consistent */
|
| 300 |
.gradio-container input[type="checkbox"] {
|
| 301 |
accent-color: var(--c-primary);
|
|
@@ -315,9 +404,9 @@ textarea:focus, input:focus {
|
|
| 315 |
/* Tab content area sometimes renders as a gray block */
|
| 316 |
.tabs,
|
| 317 |
.tabs .tabitem,
|
| 318 |
-
.tabs .tabitem
|
| 319 |
-
.tabs .tabitem
|
| 320 |
-
.tabs .tabitem
|
| 321 |
background: transparent !important;
|
| 322 |
}
|
| 323 |
|
|
@@ -349,7 +438,7 @@ input[type="range"] {
|
|
| 349 |
|
| 350 |
.gen-btn:hover {
|
| 351 |
opacity: 0.88 !important;
|
| 352 |
-
box-shadow: 0 6px 22px rgba(61,90,241,.38) !important;
|
| 353 |
transform: translateY(-1px) !important;
|
| 354 |
}
|
| 355 |
|
|
@@ -359,7 +448,7 @@ input[type="range"] {
|
|
| 359 |
}
|
| 360 |
|
| 361 |
/* Secondary buttons */
|
| 362 |
-
button:not(.gen-btn) {
|
| 363 |
border-radius: var(--r-sm) !important;
|
| 364 |
border: 1.5px solid var(--c-border) !important;
|
| 365 |
background: var(--c-surface-2) !important;
|
|
@@ -420,19 +509,33 @@ button:not(.gen-btn):hover {
|
|
| 420 |
/* ==========================================================
|
| 421 |
Accordion
|
| 422 |
========================================================== */
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
background: var(--c-surface-2) !important;
|
| 427 |
border: 1.5px solid var(--c-border) !important;
|
| 428 |
border-radius: var(--r-md) !important;
|
| 429 |
-
box-shadow: none !important;
|
| 430 |
overflow: hidden !important;
|
| 431 |
}
|
| 432 |
|
| 433 |
-
.gradio-accordion
|
| 434 |
-
.gradio-accordion
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
color: var(--c-text-2) !important;
|
| 437 |
font-weight: 700 !important;
|
| 438 |
font-size: 0.93rem !important;
|
|
@@ -442,12 +545,47 @@ button:not(.gen-btn):hover {
|
|
| 442 |
transition: background 0.14s !important;
|
| 443 |
}
|
| 444 |
|
| 445 |
-
.gradio-accordion summary:hover,
|
| 446 |
-
.gradio-accordion button:not(.gen-btn):hover
|
|
|
|
| 447 |
background: var(--c-primary-pale) !important;
|
| 448 |
border-color: transparent !important;
|
| 449 |
}
|
| 450 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
/* ==========================================================
|
| 452 |
Preview header
|
| 453 |
========================================================== */
|
|
@@ -479,7 +617,7 @@ button:not(.gen-btn):hover {
|
|
| 479 |
========================================================== */
|
| 480 |
.result-markdown {
|
| 481 |
background: var(--c-surface) !important;
|
| 482 |
-
border:
|
| 483 |
border-radius: var(--r-md) !important;
|
| 484 |
padding: 14px 16px !important;
|
| 485 |
min-height: 60px;
|
|
@@ -488,13 +626,19 @@ button:not(.gen-btn):hover {
|
|
| 488 |
|
| 489 |
.result-markdown h1,
|
| 490 |
.result-markdown h2,
|
| 491 |
-
.result-markdown h3 {
|
|
|
|
|
|
|
| 492 |
|
| 493 |
.result-markdown p,
|
| 494 |
.result-markdown li,
|
| 495 |
-
.result-markdown strong {
|
|
|
|
|
|
|
| 496 |
|
| 497 |
-
.result-markdown a {
|
|
|
|
|
|
|
| 498 |
|
| 499 |
.result-markdown code {
|
| 500 |
background: var(--c-surface-2) !important;
|
|
@@ -509,6 +653,28 @@ button:not(.gen-btn):hover {
|
|
| 509 |
border-radius: var(--r-md) !important;
|
| 510 |
}
|
| 511 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
/* ==========================================================
|
| 513 |
Tabs nav bar
|
| 514 |
========================================================== */
|
|
@@ -520,7 +686,7 @@ button:not(.gen-btn):hover {
|
|
| 520 |
padding: 10px 12px 12px !important;
|
| 521 |
}
|
| 522 |
|
| 523 |
-
.tabs
|
| 524 |
border-bottom: 2px solid var(--c-border) !important;
|
| 525 |
gap: 2px !important;
|
| 526 |
padding: 0 2px !important;
|
|
@@ -545,23 +711,51 @@ button:not(.gen-btn):hover {
|
|
| 545 |
font-weight: 600;
|
| 546 |
}
|
| 547 |
|
| 548 |
-
.footer-text a:hover {
|
|
|
|
|
|
|
| 549 |
|
| 550 |
/* ==========================================================
|
| 551 |
Responsive
|
| 552 |
========================================================== */
|
| 553 |
@media (max-width: 860px) {
|
| 554 |
-
.main-layout {
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
flex: 1 1 100% !important;
|
| 557 |
min-width: 0 !important;
|
| 558 |
padding: 12px 12px !important;
|
| 559 |
}
|
| 560 |
-
|
| 561 |
-
.
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
.
|
| 566 |
-
|
| 567 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
/* ==========================================================
|
| 2 |
RAG Learning System — Design Tokens
|
| 3 |
========================================================== */
|
| 4 |
+
:root,
|
| 5 |
+
#gradio-app {
|
| 6 |
+
--c-bg: #eef1fb;
|
| 7 |
+
--c-bg-2: #e4e9f7;
|
| 8 |
+
--c-surface: #ffffff;
|
| 9 |
+
--c-surface-2: #f2f5ff;
|
| 10 |
--c-surface-tint: rgba(255, 255, 255, 0.72);
|
| 11 |
|
| 12 |
+
--c-primary: #3d5af1;
|
| 13 |
+
--c-primary-dk: #2945d4;
|
| 14 |
--c-primary-pale: #dde4fd;
|
| 15 |
|
| 16 |
+
--c-accent: #6c3de0;
|
| 17 |
|
| 18 |
+
--c-text: #181c2e;
|
| 19 |
+
--c-text-2: #3d4460;
|
| 20 |
+
--c-text-muted: #6370a0;
|
| 21 |
|
| 22 |
/* Border needs to be darker than background for legibility */
|
| 23 |
+
--c-border: #b6c0e6;
|
| 24 |
+
--c-border-strong: #9faee0;
|
| 25 |
|
| 26 |
+
--c-note-bg: #fffbeb;
|
| 27 |
+
--c-note-bd: #f5c842;
|
| 28 |
+
--c-note-txt: #7a5200;
|
| 29 |
|
| 30 |
+
--c-status-bg: #f0f4ff;
|
| 31 |
|
| 32 |
--r-lg: 16px;
|
| 33 |
--r-md: 12px;
|
| 34 |
+
--r-sm: 8px;
|
| 35 |
+
--r-xs: 6px;
|
| 36 |
|
| 37 |
+
--shadow-card: 0 3px 16px rgba(40, 55, 130, .12), 0 1px 5px rgba(40, 55, 130, .07);
|
| 38 |
+
--shadow-panel: 0 6px 24px rgba(40, 55, 130, .10), 0 2px 8px rgba(40, 55, 130, .06);
|
| 39 |
+
--shadow-btn: 0 4px 14px rgba(61, 90, 241, .30);
|
| 40 |
+
--shadow-foc: 0 0 0 3px rgba(61, 90, 241, .20);
|
| 41 |
|
| 42 |
--app-max-width: 1440px;
|
| 43 |
}
|
|
|
|
| 46 |
Gradio variable bridge — all blocks transparent by default
|
| 47 |
========================================================== */
|
| 48 |
#gradio-app {
|
| 49 |
+
--background-fill-primary: var(--c-bg);
|
| 50 |
--background-fill-secondary: var(--c-bg-2);
|
| 51 |
+
--block-background-fill: transparent;
|
| 52 |
+
--block-border-color: transparent;
|
| 53 |
+
--block-border-width: 0px;
|
| 54 |
+
--input-background-fill: var(--c-surface);
|
| 55 |
}
|
| 56 |
|
| 57 |
/* ==========================================================
|
| 58 |
Page shell
|
| 59 |
========================================================== */
|
| 60 |
+
html,
|
| 61 |
+
body {
|
| 62 |
+
margin: 0;
|
| 63 |
+
padding: 0;
|
| 64 |
background: linear-gradient(155deg, var(--c-bg) 0%, var(--c-bg-2) 100%) !important;
|
| 65 |
color: var(--c-text);
|
| 66 |
min-height: 100vh;
|
|
|
|
| 134 |
|
| 135 |
.info-card-title,
|
| 136 |
.info-card-list,
|
| 137 |
+
.info-card-list li {
|
| 138 |
+
color: var(--c-note-txt) !important;
|
| 139 |
+
}
|
| 140 |
|
| 141 |
+
.info-card-title {
|
| 142 |
+
margin-bottom: 6px;
|
| 143 |
+
font-size: 0.95rem;
|
| 144 |
+
font-weight: 700;
|
| 145 |
+
}
|
| 146 |
|
| 147 |
.info-card-list {
|
| 148 |
margin: 0;
|
|
|
|
| 151 |
line-height: 1.65;
|
| 152 |
}
|
| 153 |
|
| 154 |
+
.info-card-list li+li {
|
| 155 |
+
margin-top: 3px;
|
| 156 |
+
}
|
| 157 |
|
| 158 |
/* ==========================================================
|
| 159 |
2-Column main layout — EXPLICIT flex, do not rely solely on Gradio
|
|
|
|
| 207 |
}
|
| 208 |
|
| 209 |
/* accent top stripe */
|
| 210 |
+
.control-card {
|
| 211 |
+
border-top: 3px solid var(--c-primary) !important;
|
| 212 |
+
}
|
| 213 |
|
| 214 |
/* strip nested .block inside cards — theme transparent already handles most,
|
| 215 |
but belt-and-suspenders for sub-blocks that may carry inline styles */
|
|
|
|
| 223 |
/* ==========================================================
|
| 224 |
Typography
|
| 225 |
========================================================== */
|
| 226 |
+
label,
|
| 227 |
+
.gr-label,
|
| 228 |
+
.block-title {
|
| 229 |
color: var(--c-text-2) !important;
|
| 230 |
font-size: 0.93rem !important;
|
| 231 |
font-weight: 600 !important;
|
|
|
|
| 244 |
/* standalone markdown outside cards (doc_list_md etc.) */
|
| 245 |
.prose p,
|
| 246 |
.prose li,
|
| 247 |
+
.prose h1,
|
| 248 |
+
.prose h2,
|
| 249 |
+
.prose h3 {
|
| 250 |
color: var(--c-text) !important;
|
| 251 |
}
|
| 252 |
|
| 253 |
/* ==========================================================
|
| 254 |
Inputs & textareas
|
| 255 |
========================================================== */
|
| 256 |
+
textarea,
|
| 257 |
+
input:not([type="range"]):not([type="checkbox"]) {
|
| 258 |
background: var(--c-surface) !important;
|
| 259 |
color: var(--c-text) !important;
|
| 260 |
font-weight: 500 !important;
|
|
|
|
| 279 |
outline: none !important;
|
| 280 |
}
|
| 281 |
|
| 282 |
+
textarea:focus,
|
| 283 |
+
input:focus {
|
| 284 |
border-color: var(--c-primary) !important;
|
| 285 |
box-shadow: var(--shadow-foc) !important;
|
| 286 |
outline: none !important;
|
| 287 |
}
|
| 288 |
|
| 289 |
+
::placeholder {
|
| 290 |
+
color: var(--c-text-muted) !important;
|
| 291 |
+
opacity: 1 !important;
|
| 292 |
+
}
|
| 293 |
|
| 294 |
/* ==========================================================
|
| 295 |
Gradio form wrappers — remove default gray panels
|
|
|
|
| 318 |
padding: 0 8px !important;
|
| 319 |
}
|
| 320 |
|
| 321 |
+
/* ==========================================================
|
| 322 |
+
Left upload block & right selection block
|
| 323 |
+
- remove nested borders (fieldset, inner inputs)
|
| 324 |
+
- let outer wrappers provide the frame
|
| 325 |
+
========================================================== */
|
| 326 |
+
.control-stack .gr-file,
|
| 327 |
+
.control-stack .gr-file *,
|
| 328 |
+
.preview-col .gr-file,
|
| 329 |
+
.preview-col .gr-file * {
|
| 330 |
+
box-shadow: none !important;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.control-stack fieldset,
|
| 334 |
+
.control-stack .gr-checkboxgroup fieldset,
|
| 335 |
+
.control-stack .gr-radiogroup fieldset,
|
| 336 |
+
.preview-col fieldset,
|
| 337 |
+
.preview-col .gr-checkboxgroup fieldset,
|
| 338 |
+
.preview-col .gr-radiogroup fieldset {
|
| 339 |
+
border: none !important;
|
| 340 |
+
background: transparent !important;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.control-stack fieldset legend,
|
| 344 |
+
.preview-col fieldset legend {
|
| 345 |
+
padding: 0 8px !important;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.control-stack .gr-checkboxgroup,
|
| 349 |
+
.control-stack .gr-dropdown,
|
| 350 |
+
.control-stack .gr-file,
|
| 351 |
+
.preview-col .gr-checkboxgroup,
|
| 352 |
+
.preview-col .gr-dropdown,
|
| 353 |
+
.preview-col .gr-file {
|
| 354 |
+
background: transparent !important;
|
| 355 |
+
border: none !important;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.control-stack .gr-file button:not(.gen-btn),
|
| 359 |
+
.preview-col .gr-file button:not(.gen-btn) {
|
| 360 |
+
border: none !important;
|
| 361 |
+
background: var(--c-surface-2) !important;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
/* Dropdown inner input: remove inner border/outline; outer wrap provides frame */
|
| 365 |
+
.preview-col .gr-dropdown input.border-none,
|
| 366 |
+
.preview-col .gr-dropdown input[role="listbox"],
|
| 367 |
+
.preview-col .wrap-inner input.border-none,
|
| 368 |
+
.preview-col .wrap-inner input[role="listbox"] {
|
| 369 |
+
border: none !important;
|
| 370 |
+
outline: none !important;
|
| 371 |
+
box-shadow: none !important;
|
| 372 |
+
background: transparent !important;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.preview-col .gr-dropdown input.border-none:focus,
|
| 376 |
+
.preview-col .gr-dropdown input[role="listbox"]:focus,
|
| 377 |
+
.preview-col .wrap-inner input.border-none:focus,
|
| 378 |
+
.preview-col .wrap-inner input[role="listbox"]:focus,
|
| 379 |
+
.preview-col .gr-dropdown input.border-none:focus-visible,
|
| 380 |
+
.preview-col .gr-dropdown input[role="listbox"]:focus-visible,
|
| 381 |
+
.preview-col .wrap-inner input.border-none:focus-visible,
|
| 382 |
+
.preview-col .wrap-inner input[role="listbox"]:focus-visible {
|
| 383 |
+
border: none !important;
|
| 384 |
+
outline: none !important;
|
| 385 |
+
box-shadow: none !important;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
/* Make option rows readable and consistent */
|
| 389 |
.gradio-container input[type="checkbox"] {
|
| 390 |
accent-color: var(--c-primary);
|
|
|
|
| 404 |
/* Tab content area sometimes renders as a gray block */
|
| 405 |
.tabs,
|
| 406 |
.tabs .tabitem,
|
| 407 |
+
.tabs .tabitem>.block,
|
| 408 |
+
.tabs .tabitem>.wrap,
|
| 409 |
+
.tabs .tabitem>.panel {
|
| 410 |
background: transparent !important;
|
| 411 |
}
|
| 412 |
|
|
|
|
| 438 |
|
| 439 |
.gen-btn:hover {
|
| 440 |
opacity: 0.88 !important;
|
| 441 |
+
box-shadow: 0 6px 22px rgba(61, 90, 241, .38) !important;
|
| 442 |
transform: translateY(-1px) !important;
|
| 443 |
}
|
| 444 |
|
|
|
|
| 448 |
}
|
| 449 |
|
| 450 |
/* Secondary buttons */
|
| 451 |
+
button:not(.gen-btn):not(.label-wrap) {
|
| 452 |
border-radius: var(--r-sm) !important;
|
| 453 |
border: 1.5px solid var(--c-border) !important;
|
| 454 |
background: var(--c-surface-2) !important;
|
|
|
|
| 509 |
/* ==========================================================
|
| 510 |
Accordion
|
| 511 |
========================================================== */
|
| 512 |
+
/* Move the "card" border to the outer block wrapper for accordion-only blocks
|
| 513 |
+
in the left panel, and keep inner accordion elements borderless. */
|
| 514 |
+
.control-stack .block.padded.auto-margin {
|
| 515 |
+
background: transparent !important;
|
| 516 |
+
border: none !important;
|
| 517 |
+
box-shadow: none !important;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
.control-stack .block.padded.auto-margin:has(.gradio-accordion) {
|
| 521 |
background: var(--c-surface-2) !important;
|
| 522 |
border: 1.5px solid var(--c-border) !important;
|
| 523 |
border-radius: var(--r-md) !important;
|
|
|
|
| 524 |
overflow: hidden !important;
|
| 525 |
}
|
| 526 |
|
| 527 |
+
.control-stack .gradio-accordion,
|
| 528 |
+
.control-stack .gradio-accordion>div,
|
| 529 |
+
.control-stack .gradio-accordion details {
|
| 530 |
+
background: transparent !important;
|
| 531 |
+
border: none !important;
|
| 532 |
+
box-shadow: none !important;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
.control-stack .gradio-accordion summary,
|
| 536 |
+
.control-stack .gradio-accordion button,
|
| 537 |
+
.control-stack .gradio-accordion .label-wrap {
|
| 538 |
+
background: transparent !important;
|
| 539 |
color: var(--c-text-2) !important;
|
| 540 |
font-weight: 700 !important;
|
| 541 |
font-size: 0.93rem !important;
|
|
|
|
| 545 |
transition: background 0.14s !important;
|
| 546 |
}
|
| 547 |
|
| 548 |
+
.control-stack .gradio-accordion summary:hover,
|
| 549 |
+
.control-stack .gradio-accordion button:not(.gen-btn):hover,
|
| 550 |
+
.control-stack .gradio-accordion .label-wrap:hover {
|
| 551 |
background: var(--c-primary-pale) !important;
|
| 552 |
border-color: transparent !important;
|
| 553 |
}
|
| 554 |
|
| 555 |
+
/* Ensure accordion header button never gets an inner border */
|
| 556 |
+
.control-stack button.label-wrap,
|
| 557 |
+
.control-stack .block.padded.auto-margin:has(.gradio-accordion) button.label-wrap {
|
| 558 |
+
border: none !important;
|
| 559 |
+
outline: none !important;
|
| 560 |
+
box-shadow: none !important;
|
| 561 |
+
background: transparent !important;
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
/* Status bar inside left panel blocks: borderless, let outer wrapper frame it */
|
| 565 |
+
.control-stack .block.padded.auto-margin:has(.gradio-accordion) .status-bar {
|
| 566 |
+
border: none !important;
|
| 567 |
+
border-left: none !important;
|
| 568 |
+
background: transparent !important;
|
| 569 |
+
padding: 8px 12px !important;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
/* Document selection checkbox options container (right panel) */
|
| 573 |
+
.preview-col .wrap-inner,
|
| 574 |
+
.preview-col .gr-checkboxgroup .wrap-inner {
|
| 575 |
+
border: 1.5px solid var(--c-border-strong) !important;
|
| 576 |
+
border-radius: var(--r-md) !important;
|
| 577 |
+
background: var(--c-surface) !important;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
/* Tabs: ensure tab buttons are borderless */
|
| 581 |
+
.tab-container button[role="tab"],
|
| 582 |
+
.tab-container button[role="tab"].selected,
|
| 583 |
+
.tab-wrapper button[role="tab"] {
|
| 584 |
+
border: none !important;
|
| 585 |
+
box-shadow: none !important;
|
| 586 |
+
outline: none !important;
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
/* ==========================================================
|
| 590 |
Preview header
|
| 591 |
========================================================== */
|
|
|
|
| 617 |
========================================================== */
|
| 618 |
.result-markdown {
|
| 619 |
background: var(--c-surface) !important;
|
| 620 |
+
border: none !important;
|
| 621 |
border-radius: var(--r-md) !important;
|
| 622 |
padding: 14px 16px !important;
|
| 623 |
min-height: 60px;
|
|
|
|
| 626 |
|
| 627 |
.result-markdown h1,
|
| 628 |
.result-markdown h2,
|
| 629 |
+
.result-markdown h3 {
|
| 630 |
+
color: var(--c-primary-dk) !important;
|
| 631 |
+
}
|
| 632 |
|
| 633 |
.result-markdown p,
|
| 634 |
.result-markdown li,
|
| 635 |
+
.result-markdown strong {
|
| 636 |
+
color: var(--c-text) !important;
|
| 637 |
+
}
|
| 638 |
|
| 639 |
+
.result-markdown a {
|
| 640 |
+
color: var(--c-primary) !important;
|
| 641 |
+
}
|
| 642 |
|
| 643 |
.result-markdown code {
|
| 644 |
background: var(--c-surface-2) !important;
|
|
|
|
| 653 |
border-radius: var(--r-md) !important;
|
| 654 |
}
|
| 655 |
|
| 656 |
+
/* ==========================================================
|
| 657 |
+
Inline citations (HTML <details>) inside result markdown
|
| 658 |
+
========================================================== */
|
| 659 |
+
.result-markdown details {
|
| 660 |
+
background: var(--c-surface-2);
|
| 661 |
+
border: 1px solid var(--c-border);
|
| 662 |
+
border-radius: var(--r-sm);
|
| 663 |
+
padding: 8px 10px;
|
| 664 |
+
margin-top: 8px;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.result-markdown summary {
|
| 668 |
+
cursor: pointer;
|
| 669 |
+
font-weight: 700;
|
| 670 |
+
color: var(--c-text-2);
|
| 671 |
+
outline: none;
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
.result-markdown details[open] summary {
|
| 675 |
+
color: var(--c-primary-dk);
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
/* ==========================================================
|
| 679 |
Tabs nav bar
|
| 680 |
========================================================== */
|
|
|
|
| 686 |
padding: 10px 12px 12px !important;
|
| 687 |
}
|
| 688 |
|
| 689 |
+
.tabs>.tab-nav {
|
| 690 |
border-bottom: 2px solid var(--c-border) !important;
|
| 691 |
gap: 2px !important;
|
| 692 |
padding: 0 2px !important;
|
|
|
|
| 711 |
font-weight: 600;
|
| 712 |
}
|
| 713 |
|
| 714 |
+
.footer-text a:hover {
|
| 715 |
+
text-decoration: underline;
|
| 716 |
+
}
|
| 717 |
|
| 718 |
/* ==========================================================
|
| 719 |
Responsive
|
| 720 |
========================================================== */
|
| 721 |
@media (max-width: 860px) {
|
| 722 |
+
.main-layout {
|
| 723 |
+
flex-direction: column !important;
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
.control-stack,
|
| 727 |
+
.preview-col {
|
| 728 |
flex: 1 1 100% !important;
|
| 729 |
min-width: 0 !important;
|
| 730 |
padding: 12px 12px !important;
|
| 731 |
}
|
| 732 |
+
|
| 733 |
+
.gradio-container {
|
| 734 |
+
padding: 6px 8px !important;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
.header-row {
|
| 738 |
+
gap: 8px !important;
|
| 739 |
+
padding: 2px 0 6px !important;
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
.header-row img {
|
| 743 |
+
height: 56px;
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
.header-title {
|
| 747 |
+
font-size: 1.35rem;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
.header-sub {
|
| 751 |
+
font-size: 0.87rem;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
.control-card {
|
| 755 |
+
padding: 10px 12px !important;
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
.tabs {
|
| 759 |
+
padding: 8px 8px 10px !important;
|
| 760 |
+
}
|
| 761 |
+
}
|