FocusFlow Assistant commited on
Commit ·
e60bafd
1
Parent(s): 8008633
feat: Implement AI Plan Generator (Lite Mode) and Focus Mode Layout
Browse files- app.py +556 -379
- backend/main.py +41 -3
- backend/rag_engine.py +183 -9
app.py
CHANGED
|
@@ -220,6 +220,19 @@ st.markdown("""
|
|
| 220 |
cursor: pointer;
|
| 221 |
}
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
</style>
|
| 224 |
""", unsafe_allow_html=True)
|
| 225 |
|
|
@@ -231,12 +244,26 @@ if "timer_running" not in st.session_state: st.session_state.timer_running = Fal
|
|
| 231 |
if "expiry_time" not in st.session_state: st.session_state.expiry_time = None
|
| 232 |
if "time_left_m" not in st.session_state: st.session_state.time_left_m = 0
|
| 233 |
if "time_left_s" not in st.session_state: st.session_state.time_left_s = 0
|
| 234 |
-
if "uploaded_files" not in st.session_state: st.session_state.uploaded_files = []
|
| 235 |
if "chat_history" not in st.session_state: st.session_state.chat_history = []
|
| 236 |
if "mastery_data" not in st.session_state: st.session_state.mastery_data = {"S1": 0, "S2": 0, "S3": 0, "S4": 0}
|
| 237 |
if "expanded_topics" not in st.session_state: st.session_state.expanded_topics = set()
|
| 238 |
if "show_analytics" not in st.session_state: st.session_state.show_analytics = False
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
def check_internet():
|
| 241 |
"""
|
| 242 |
Checks for internet connectivity by pinging reliable hosts.
|
|
@@ -366,10 +393,32 @@ def show_quiz_dialog(topic_id, topic_name):
|
|
| 366 |
# -----------------------------------------------------------------------------
|
| 367 |
@st.dialog("Topic Mastery Quiz")
|
| 368 |
def show_quiz_dialog(topic_id, topic_name):
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
|
| 374 |
# (The previous tool usage showed show_quiz_dialog was inserted. I will target the end of it to insert Flashcards)
|
| 375 |
# Actually, let's just insert it before MAIN LAYOUT, which is clearer.
|
|
@@ -453,415 +502,543 @@ def show_flashcard_dialog(topic_id, topic_name):
|
|
| 453 |
st.rerun()
|
| 454 |
else:
|
| 455 |
st.button("Next Card →", disabled=True, use_container_width=True) # Lock next until flipped? Or allow skipping. Let's lock to encourage reading.
|
| 456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
|
| 458 |
# --- LEFT COLUMN: Control Center ---
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
with st.container(border=True):
|
| 464 |
-
st.markdown('<div style="text-align: center; font-weight: 600; color: #374151; margin-bottom: 10px;">Study Timer</div>', unsafe_allow_html=True)
|
| 465 |
-
|
| 466 |
-
# Timer Logic
|
| 467 |
-
total_seconds = (st.session_state.time_left_m * 60) + st.session_state.time_left_s
|
| 468 |
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
st.
|
| 479 |
-
else:
|
| 480 |
-
# Render JS Timer (Non-blocking)
|
| 481 |
-
|
| 482 |
-
# We need to inject the SAME styles to match the look.
|
| 483 |
-
# Since components run in iframe, we copy the CSS.
|
| 484 |
-
m, s = divmod(int(remaining), 60)
|
| 485 |
|
| 486 |
-
|
| 487 |
-
<!DOCTYPE html>
|
| 488 |
-
<html>
|
| 489 |
-
<head>
|
| 490 |
-
<style>
|
| 491 |
-
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 492 |
-
body {{
|
| 493 |
-
font-family: 'Inter', sans-serif;
|
| 494 |
-
margin: 0;
|
| 495 |
-
display: flex;
|
| 496 |
-
justify-content: center;
|
| 497 |
-
align-items: center;
|
| 498 |
-
background: transparent;
|
| 499 |
-
height: 100px; /* specific height */
|
| 500 |
-
}}
|
| 501 |
-
.timer-display {{
|
| 502 |
-
display: flex;
|
| 503 |
-
align-items: center;
|
| 504 |
-
justify-content: center;
|
| 505 |
-
gap: 10px;
|
| 506 |
-
}}
|
| 507 |
-
.timer-box {{
|
| 508 |
-
background: white;
|
| 509 |
-
border: 2px solid #374151;
|
| 510 |
-
border-radius: 8px;
|
| 511 |
-
width: 80px;
|
| 512 |
-
height: 80px;
|
| 513 |
-
display: flex;
|
| 514 |
-
align-items: center;
|
| 515 |
-
justify-content: center;
|
| 516 |
-
font-size: 2.5rem;
|
| 517 |
-
font-weight: 700;
|
| 518 |
-
color: #111827;
|
| 519 |
-
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
| 520 |
-
}}
|
| 521 |
-
.timer-dots {{
|
| 522 |
-
display: flex;
|
| 523 |
-
flex-direction: column;
|
| 524 |
-
gap: 8px;
|
| 525 |
-
}}
|
| 526 |
-
.dot {{
|
| 527 |
-
width: 6px;
|
| 528 |
-
height: 6px;
|
| 529 |
-
background: #374151;
|
| 530 |
-
border-radius: 50%;
|
| 531 |
-
}}
|
| 532 |
-
</style>
|
| 533 |
-
<script>
|
| 534 |
-
function startTimer(duration, display) {{
|
| 535 |
-
var timer = duration, minutes, seconds;
|
| 536 |
-
var interval = setInterval(function () {{
|
| 537 |
-
minutes = parseInt(timer / 60, 10);
|
| 538 |
-
seconds = parseInt(timer % 60, 10);
|
| 539 |
-
|
| 540 |
-
minutes = minutes < 10 ? "0" + minutes : minutes;
|
| 541 |
-
seconds = seconds < 10 ? "0" + seconds : seconds;
|
| 542 |
-
|
| 543 |
-
document.getElementById('m').textContent = minutes;
|
| 544 |
-
document.getElementById('s').textContent = seconds;
|
| 545 |
-
|
| 546 |
-
if (--timer < 0) {{
|
| 547 |
-
clearInterval(interval);
|
| 548 |
-
// Optional: Signal finish?
|
| 549 |
-
}}
|
| 550 |
-
}}, 1000);
|
| 551 |
-
}}
|
| 552 |
-
|
| 553 |
-
window.onload = function () {{
|
| 554 |
-
var remaining = {int(remaining)};
|
| 555 |
-
startTimer(remaining);
|
| 556 |
-
}};
|
| 557 |
-
</script>
|
| 558 |
-
</head>
|
| 559 |
-
<body>
|
| 560 |
-
<div class="timer-display">
|
| 561 |
-
<div class="timer-box" id="m">{m:02d}</div>
|
| 562 |
-
<div class="timer-dots">
|
| 563 |
-
<div class="dot"></div>
|
| 564 |
-
<div class="dot"></div>
|
| 565 |
-
</div>
|
| 566 |
-
<div class="timer-box" id="s">{s:02d}</div>
|
| 567 |
-
</div>
|
| 568 |
-
</body>
|
| 569 |
-
</html>
|
| 570 |
-
"""
|
| 571 |
-
components.html(html_code, height=120)
|
| 572 |
-
|
| 573 |
-
# Show ONLY Stop Button
|
| 574 |
-
if st.button("STOP", use_container_width=True, type="secondary"):
|
| 575 |
st.session_state.timer_running = False
|
| 576 |
st.session_state.expiry_time = None
|
|
|
|
|
|
|
| 577 |
st.rerun()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
# Use columns to center inputs
|
| 582 |
-
c1, c2, c3 = st.columns([0.45, 0.1, 0.45])
|
| 583 |
-
with c1:
|
| 584 |
-
st.number_input("Min", min_value=0, max_value=999, label_visibility="collapsed", key="time_left_m")
|
| 585 |
-
with c2:
|
| 586 |
-
st.markdown("<div class='timer-colon'>:</div>", unsafe_allow_html=True)
|
| 587 |
-
with c3:
|
| 588 |
-
st.number_input("Sec", min_value=0, max_value=59, label_visibility="collapsed", key="time_left_s")
|
| 589 |
-
|
| 590 |
-
st.write("") # Spacer
|
| 591 |
-
|
| 592 |
-
# Start Button
|
| 593 |
-
if st.button("START", use_container_width=True, type="primary"):
|
| 594 |
-
total_seconds = (st.session_state.time_left_m * 60) + st.session_state.time_left_s
|
| 595 |
-
if total_seconds > 0:
|
| 596 |
-
st.session_state.timer_running = True
|
| 597 |
-
st.session_state.expiry_time = time.time() + total_seconds
|
| 598 |
-
st.rerun()
|
| 599 |
-
|
| 600 |
-
# Sources Widget
|
| 601 |
-
with st.container(border=True):
|
| 602 |
-
# Connectivity Check
|
| 603 |
-
is_online = check_internet()
|
| 604 |
-
status_color = "online" if is_online else "offline"
|
| 605 |
-
status_text = "ONLINE" if is_online else "OFFLINE"
|
| 606 |
-
|
| 607 |
-
st.markdown(f"""
|
| 608 |
-
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
| 609 |
-
<h4 style="margin:0">Sources</h4>
|
| 610 |
-
<span class="status-badge {status_color}">{status_text}</span>
|
| 611 |
-
</div>
|
| 612 |
-
""", unsafe_allow_html=True)
|
| 613 |
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
# Helper to fetch sources
|
| 618 |
-
sources_list = []
|
| 619 |
-
try:
|
| 620 |
-
s_resp = requests.get(f"{API_URL}/sources")
|
| 621 |
-
if s_resp.status_code == 200:
|
| 622 |
-
sources_list = s_resp.json()
|
| 623 |
-
except:
|
| 624 |
-
pass
|
| 625 |
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
<
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
</div>
|
| 646 |
""", unsafe_allow_html=True)
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
# Optimistically update UI by removing from list or just rerun
|
| 651 |
-
requests.delete(f"{API_URL}/sources/{src['id']}")
|
| 652 |
-
time.sleep(0.1) # Small delay for DB prop
|
| 653 |
-
st.rerun()
|
| 654 |
-
except Exception as e:
|
| 655 |
-
st.error(f"Error: {e}")
|
| 656 |
|
| 657 |
-
#
|
| 658 |
-
with st.expander("+ Add
|
| 659 |
uploaded = st.file_uploader("Upload PDF", type=["pdf", "txt"], label_visibility="collapsed")
|
| 660 |
if uploaded:
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
time.sleep(0.5)
|
| 665 |
-
st.rerun()
|
| 666 |
-
|
| 667 |
-
with tab_online:
|
| 668 |
-
# Filter online files
|
| 669 |
-
online_files = [f for f in st.session_state.uploaded_files if f.startswith("WEB:")]
|
| 670 |
-
|
| 671 |
-
for f in online_files:
|
| 672 |
-
st.markdown(f"""
|
| 673 |
-
<div class="source-item">
|
| 674 |
-
<span class="source-icon">🌐</span>
|
| 675 |
-
<span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{f.replace("WEB: ", "")}</span>
|
| 676 |
-
</div>
|
| 677 |
-
""", unsafe_allow_html=True)
|
| 678 |
-
|
| 679 |
-
# Using a form to prevent reload from clearing state before processing
|
| 680 |
-
with st.form("fetch_form"):
|
| 681 |
-
url = st.text_input("Enter URL", placeholder="https://marktex.ai")
|
| 682 |
-
submitted = st.form_submit_button("Fetch")
|
| 683 |
-
if submitted and url:
|
| 684 |
-
st.session_state.uploaded_files.append(f"WEB: {url}")
|
| 685 |
-
st.success("Fetched!")
|
| 686 |
-
time.sleep(0.5)
|
| 687 |
-
st.rerun()
|
| 688 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
|
| 690 |
-
#
|
| 691 |
-
with
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
with h_col2:
|
| 697 |
-
if st.button("📊 Analytics"):
|
| 698 |
-
show_analytics_dialog()
|
| 699 |
-
|
| 700 |
-
# Reading Content / Chat Area
|
| 701 |
-
# Use native container with border to replace "custom-card" and fix "small box" issue
|
| 702 |
-
with st.container(border=True):
|
| 703 |
-
|
| 704 |
-
# 1. Chat History / Content (Scrollable Container)
|
| 705 |
-
# using height=500 to create a scrolling area like a real chat app
|
| 706 |
-
chat_container = st.container(height=500)
|
| 707 |
-
|
| 708 |
-
with chat_container:
|
| 709 |
-
if not st.session_state.chat_history:
|
| 710 |
-
# Welcome Content
|
| 711 |
-
st.markdown('<div class="article-title">Welcome to FocusFlow</div>', unsafe_allow_html=True)
|
| 712 |
-
st.markdown("""
|
| 713 |
-
<div class="article-text">
|
| 714 |
-
This is your intelligent workspace. <br>
|
| 715 |
-
Upload a PDF in the sources panel to get started, or ask a question below.
|
| 716 |
-
<br><br>
|
| 717 |
-
Your content will appear here...
|
| 718 |
-
</div>
|
| 719 |
-
""", unsafe_allow_html=True)
|
| 720 |
-
else:
|
| 721 |
-
# Chat Messages
|
| 722 |
-
for msg in st.session_state.chat_history:
|
| 723 |
-
role = msg["role"]
|
| 724 |
-
content = msg["content"]
|
| 725 |
-
|
| 726 |
-
if role == "user":
|
| 727 |
-
st.chat_message("user").write(content)
|
| 728 |
else:
|
| 729 |
-
with st.
|
| 730 |
-
st.markdown(content)
|
| 731 |
-
if "sources" in msg and msg["sources"]:
|
| 732 |
-
st.markdown("---")
|
| 733 |
-
st.caption("Sources used:")
|
| 734 |
-
for s in msg["sources"]:
|
| 735 |
-
label = f"📄 {s['source']} | Page {s['page']}"
|
| 736 |
-
with st.expander(label):
|
| 737 |
-
st.markdown(f"_{s.get('content', 'No snippet available').strip()}_")
|
| 738 |
-
|
| 739 |
-
# 2. Input Area (Pinned to bottom of the visible card by being outside scroll container)
|
| 740 |
-
with st.form(key="chat_form", clear_on_submit=True):
|
| 741 |
-
cols = st.columns([0.85, 0.15])
|
| 742 |
-
with cols[0]:
|
| 743 |
-
user_input = st.text_input("Ask a question...", placeholder="Ask a question about your documents...", label_visibility="collapsed", key="chat_input_widget")
|
| 744 |
-
with cols[1]:
|
| 745 |
-
submit_button = st.form_submit_button("Send", use_container_width=True)
|
| 746 |
-
|
| 747 |
-
if submit_button and user_input:
|
| 748 |
-
st.session_state.chat_history.append({"role": "user", "content": user_input})
|
| 749 |
-
|
| 750 |
-
try:
|
| 751 |
-
with st.spinner("Thinking..."):
|
| 752 |
-
# Prepare history (exclude sources for cleanliness)
|
| 753 |
-
history = [
|
| 754 |
-
{"role": msg["role"], "content": msg["content"]}
|
| 755 |
-
for msg in st.session_state.chat_history[:-1][-5:] # Last 5 valid history items before current question
|
| 756 |
-
]
|
| 757 |
-
|
| 758 |
-
resp = requests.post(f"{API_URL}/query", json={"question": user_input, "history": history})
|
| 759 |
-
if resp.status_code == 200:
|
| 760 |
try:
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
|
|
|
| 766 |
else:
|
| 767 |
-
st.
|
| 768 |
except Exception as e:
|
| 769 |
-
st.
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
|
| 775 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
|
| 777 |
|
| 778 |
# --- RIGHT COLUMN: Scheduler ---
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
st.markdown(
|
| 785 |
-
st.markdown('<h4>Calendar Agent</h4>', unsafe_allow_html=True)
|
| 786 |
|
| 787 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
calendar_options = {
|
| 789 |
-
|
| 790 |
-
"
|
| 791 |
-
|
| 792 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
}
|
| 794 |
-
calendar(events=[], options=calendar_options, key="mini_cal")
|
| 795 |
|
| 796 |
-
|
| 797 |
|
| 798 |
-
|
|
|
|
|
|
|
|
|
|
| 799 |
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 821 |
|
| 822 |
-
|
| 823 |
-
#
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
# Custom expander-like behavior
|
| 830 |
-
is_expanded = f"topic_{t_id}" in st.session_state.expanded_topics
|
| 831 |
-
|
| 832 |
-
# Header Row
|
| 833 |
-
cols = st.columns([0.1, 0.8, 0.1])
|
| 834 |
-
|
| 835 |
-
# 1. Checkbox (Visual mostly, or tracks completion)
|
| 836 |
-
# If valid completion logic exists, we could use it. For now disable if locked.
|
| 837 |
-
cols[0].checkbox("", value=is_completed, key=f"cb_{t_id}", disabled=is_locked or is_completed, label_visibility="collapsed")
|
| 838 |
|
| 839 |
-
#
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
else:
|
| 846 |
-
st.session_state.expanded_topics.add(f"topic_{t_id}")
|
| 847 |
-
st.rerun()
|
| 848 |
-
|
| 849 |
-
# Expanded Content
|
| 850 |
-
if is_expanded and not is_locked:
|
| 851 |
-
st.markdown(f"""
|
| 852 |
-
<div style="margin-left: 20px; margin-top: 5px; margin-bottom: 15px; border-left: 2px solid #E5E7EB; padding-left: 10px;">
|
| 853 |
-
<p style="font-size: 0.9rem; color: #6B7280; margin-bottom: 10px;">Mastery Required: 80%</p>
|
| 854 |
-
""", unsafe_allow_html=True)
|
| 855 |
|
| 856 |
-
#
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
with
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 867 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
cursor: pointer;
|
| 221 |
}
|
| 222 |
|
| 223 |
+
/* --- CALENDAR HORIZONTAL FORCE --- */
|
| 224 |
+
/* CSS cannot penetrate the iframe, so we rely on component options now. */
|
| 225 |
+
/* Keeping the container clean */
|
| 226 |
+
/* Fix Calendar Title Size for sidebar */
|
| 227 |
+
.fc-toolbar-title {
|
| 228 |
+
font-size: 1.1rem !important; /* Smaller size to fit sidebar */
|
| 229 |
+
white-space: normal !important; /* Allow wrapping if needed? Or shrink more */
|
| 230 |
+
}
|
| 231 |
+
@media (max-width: 1400px) {
|
| 232 |
+
.fc-toolbar-title {
|
| 233 |
+
font-size: 0.9rem !important; /* Aggressively smaller on small screens */
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
</style>
|
| 237 |
""", unsafe_allow_html=True)
|
| 238 |
|
|
|
|
| 244 |
if "expiry_time" not in st.session_state: st.session_state.expiry_time = None
|
| 245 |
if "time_left_m" not in st.session_state: st.session_state.time_left_m = 0
|
| 246 |
if "time_left_s" not in st.session_state: st.session_state.time_left_s = 0
|
|
|
|
| 247 |
if "chat_history" not in st.session_state: st.session_state.chat_history = []
|
| 248 |
if "mastery_data" not in st.session_state: st.session_state.mastery_data = {"S1": 0, "S2": 0, "S3": 0, "S4": 0}
|
| 249 |
if "expanded_topics" not in st.session_state: st.session_state.expanded_topics = set()
|
| 250 |
if "show_analytics" not in st.session_state: st.session_state.show_analytics = False
|
| 251 |
|
| 252 |
+
# Focus Mode State
|
| 253 |
+
if "focus_mode" not in st.session_state: st.session_state.focus_mode = False
|
| 254 |
+
if "active_topic" not in st.session_state: st.session_state.active_topic = None
|
| 255 |
+
if "study_plan" not in st.session_state:
|
| 256 |
+
# START EMPTY as requested
|
| 257 |
+
st.session_state.study_plan = []
|
| 258 |
+
|
| 259 |
+
# ... (check_internet remains)
|
| 260 |
+
|
| 261 |
+
# ... (Search logic remains)
|
| 262 |
+
|
| 263 |
+
# ...
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
|
| 267 |
def check_internet():
|
| 268 |
"""
|
| 269 |
Checks for internet connectivity by pinging reliable hosts.
|
|
|
|
| 393 |
# -----------------------------------------------------------------------------
|
| 394 |
@st.dialog("Topic Mastery Quiz")
|
| 395 |
def show_quiz_dialog(topic_id, topic_name):
|
| 396 |
+
st.markdown(f"**Topic:** {topic_name}")
|
| 397 |
+
st.markdown("To unlock the next topic, you must pass this quiz.")
|
| 398 |
+
|
| 399 |
+
# Mock Question
|
| 400 |
+
st.info("Question: What is the primary concept of this topic?")
|
| 401 |
+
|
| 402 |
+
ans = st.radio("Select Answer:", ["Energy Conservation", "Wrong Answer 1", "Wrong Answer 2"], key=f"q_radio_{topic_id}")
|
| 403 |
+
|
| 404 |
+
if st.button("Submit Answer", type="primary"):
|
| 405 |
+
if ans == "Energy Conservation":
|
| 406 |
+
st.balloons()
|
| 407 |
+
st.success("Correct! Next topic unlocked.")
|
| 408 |
+
|
| 409 |
+
# Update Mock State
|
| 410 |
+
for i, t in enumerate(st.session_state.study_plan):
|
| 411 |
+
if t["id"] == topic_id:
|
| 412 |
+
t["quiz_passed"] = True
|
| 413 |
+
# Unlock next
|
| 414 |
+
if i + 1 < len(st.session_state.study_plan):
|
| 415 |
+
st.session_state.study_plan[i+1]["status"] = "unlocked"
|
| 416 |
+
break
|
| 417 |
+
|
| 418 |
+
time.sleep(1.5)
|
| 419 |
+
st.rerun()
|
| 420 |
+
else:
|
| 421 |
+
st.error("Incorrect. Try again.")
|
| 422 |
|
| 423 |
# (The previous tool usage showed show_quiz_dialog was inserted. I will target the end of it to insert Flashcards)
|
| 424 |
# Actually, let's just insert it before MAIN LAYOUT, which is clearer.
|
|
|
|
| 502 |
st.rerun()
|
| 503 |
else:
|
| 504 |
st.button("Next Card →", disabled=True, use_container_width=True) # Lock next until flipped? Or allow skipping. Let's lock to encourage reading.
|
| 505 |
+
# --- LAYOUT SWITCHER ---
|
| 506 |
+
if not st.session_state.focus_mode:
|
| 507 |
+
# Standard 3-Column Layout
|
| 508 |
+
left_col, mid_col, right_col = st.columns([0.25, 0.50, 0.25], gap="medium")
|
| 509 |
+
else:
|
| 510 |
+
# Focus Mode Layout (2 Columns: Chat + Content)
|
| 511 |
+
left_col, mid_col = st.columns([0.30, 0.70], gap="large")
|
| 512 |
+
right_col = None # Not used in Focus Mode
|
| 513 |
|
| 514 |
# --- LEFT COLUMN: Control Center ---
|
| 515 |
+
# --- LEFT COLUMN: Control Center ---
|
| 516 |
+
if not st.session_state.focus_mode:
|
| 517 |
+
with left_col:
|
| 518 |
+
st.markdown("### Control Center")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
|
| 520 |
+
# Timer Widget
|
| 521 |
+
with st.container(border=True):
|
| 522 |
+
st.markdown('<div style="text-align: center; font-weight: 600; color: #374151; margin-bottom: 10px;">Study Timer</div>', unsafe_allow_html=True)
|
| 523 |
|
| 524 |
+
# Timer Logic
|
| 525 |
+
total_seconds = (st.session_state.time_left_m * 60) + st.session_state.time_left_s
|
| 526 |
+
|
| 527 |
+
if st.session_state.timer_running:
|
| 528 |
+
# Check if time is up
|
| 529 |
+
remaining = st.session_state.expiry_time - time.time()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
|
| 531 |
+
if remaining <= 0:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
st.session_state.timer_running = False
|
| 533 |
st.session_state.expiry_time = None
|
| 534 |
+
st.session_state.time_left_m, st.session_state.time_left_s = 0, 0
|
| 535 |
+
st.balloons()
|
| 536 |
st.rerun()
|
| 537 |
+
else:
|
| 538 |
+
# Render JS Timer (Non-blocking)
|
| 539 |
+
|
| 540 |
+
# We need to inject the SAME styles to match the look.
|
| 541 |
+
# Since components run in iframe, we copy the CSS.
|
| 542 |
+
m, s = divmod(int(remaining), 60)
|
| 543 |
+
|
| 544 |
+
html_code = f"""
|
| 545 |
+
<!DOCTYPE html>
|
| 546 |
+
<html>
|
| 547 |
+
<head>
|
| 548 |
+
<style>
|
| 549 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 550 |
+
body {{
|
| 551 |
+
font-family: 'Inter', sans-serif;
|
| 552 |
+
margin: 0;
|
| 553 |
+
display: flex;
|
| 554 |
+
justify-content: center;
|
| 555 |
+
align-items: center;
|
| 556 |
+
background: transparent;
|
| 557 |
+
height: 100px; /* specific height */
|
| 558 |
+
}}
|
| 559 |
+
.timer-display {{
|
| 560 |
+
display: flex;
|
| 561 |
+
align-items: center;
|
| 562 |
+
justify-content: center;
|
| 563 |
+
gap: 10px;
|
| 564 |
+
}}
|
| 565 |
+
.timer-box {{
|
| 566 |
+
background: white;
|
| 567 |
+
border: 2px solid #374151;
|
| 568 |
+
border-radius: 8px;
|
| 569 |
+
width: 80px;
|
| 570 |
+
height: 80px;
|
| 571 |
+
display: flex;
|
| 572 |
+
align-items: center;
|
| 573 |
+
justify-content: center;
|
| 574 |
+
font-size: 2.5rem;
|
| 575 |
+
font-weight: 700;
|
| 576 |
+
color: #111827;
|
| 577 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
| 578 |
+
}}
|
| 579 |
+
.timer-dots {{
|
| 580 |
+
display: flex;
|
| 581 |
+
flex-direction: column;
|
| 582 |
+
gap: 8px;
|
| 583 |
+
}}
|
| 584 |
+
.dot {{
|
| 585 |
+
width: 6px;
|
| 586 |
+
height: 6px;
|
| 587 |
+
background: #374151;
|
| 588 |
+
border-radius: 50%;
|
| 589 |
+
}}
|
| 590 |
+
</style>
|
| 591 |
+
<script>
|
| 592 |
+
function startTimer(duration, display) {{
|
| 593 |
+
var timer = duration, minutes, seconds;
|
| 594 |
+
var interval = setInterval(function () {{
|
| 595 |
+
minutes = parseInt(timer / 60, 10);
|
| 596 |
+
seconds = parseInt(timer % 60, 10);
|
| 597 |
|
| 598 |
+
minutes = minutes < 10 ? "0" + minutes : minutes;
|
| 599 |
+
seconds = seconds < 10 ? "0" + seconds : seconds;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
|
| 601 |
+
document.getElementById('m').textContent = minutes;
|
| 602 |
+
document.getElementById('s').textContent = seconds;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
|
| 604 |
+
if (--timer < 0) {{
|
| 605 |
+
clearInterval(interval);
|
| 606 |
+
// Optional: Signal finish?
|
| 607 |
+
}}
|
| 608 |
+
}}, 1000);
|
| 609 |
+
}}
|
| 610 |
+
|
| 611 |
+
window.onload = function () {{
|
| 612 |
+
var remaining = {int(remaining)};
|
| 613 |
+
startTimer(remaining);
|
| 614 |
+
}};
|
| 615 |
+
</script>
|
| 616 |
+
</head>
|
| 617 |
+
<body>
|
| 618 |
+
<div class="timer-display">
|
| 619 |
+
<div class="timer-box" id="m">{m:02d}</div>
|
| 620 |
+
<div class="timer-dots">
|
| 621 |
+
<div class="dot"></div>
|
| 622 |
+
<div class="dot"></div>
|
| 623 |
+
</div>
|
| 624 |
+
<div class="timer-box" id="s">{s:02d}</div>
|
| 625 |
+
</div>
|
| 626 |
+
</body>
|
| 627 |
+
</html>
|
| 628 |
+
"""
|
| 629 |
+
components.html(html_code, height=120)
|
| 630 |
+
|
| 631 |
+
# Show ONLY Stop Button
|
| 632 |
+
if st.button("STOP", use_container_width=True, type="secondary"):
|
| 633 |
+
st.session_state.timer_running = False
|
| 634 |
+
st.session_state.expiry_time = None
|
| 635 |
+
st.rerun()
|
| 636 |
+
|
| 637 |
+
else:
|
| 638 |
+
# Editable Inputs (Only show when STOPPED)
|
| 639 |
+
# Use columns to center inputs
|
| 640 |
+
c1, c2, c3 = st.columns([0.45, 0.1, 0.45])
|
| 641 |
+
with c1:
|
| 642 |
+
st.number_input("Min", min_value=0, max_value=999, label_visibility="collapsed", key="time_left_m")
|
| 643 |
+
with c2:
|
| 644 |
+
st.markdown("<div class='timer-colon'>:</div>", unsafe_allow_html=True)
|
| 645 |
+
with c3:
|
| 646 |
+
st.number_input("Sec", min_value=0, max_value=59, label_visibility="collapsed", key="time_left_s")
|
| 647 |
+
|
| 648 |
+
st.write("") # Spacer
|
| 649 |
+
|
| 650 |
+
# Start Button
|
| 651 |
+
if st.button("START", use_container_width=True, type="primary"):
|
| 652 |
+
total_seconds = (st.session_state.time_left_m * 60) + st.session_state.time_left_s
|
| 653 |
+
if total_seconds > 0:
|
| 654 |
+
st.session_state.timer_running = True
|
| 655 |
+
st.session_state.expiry_time = time.time() + total_seconds
|
| 656 |
+
st.rerun()
|
| 657 |
+
|
| 658 |
+
# Sources Widget
|
| 659 |
+
with st.container(border=True):
|
| 660 |
+
# Connectivity Check
|
| 661 |
+
is_online = check_internet()
|
| 662 |
+
status_color = "online" if is_online else "offline"
|
| 663 |
+
status_text = "ONLINE" if is_online else "OFFLINE"
|
| 664 |
|
| 665 |
+
st.markdown(f"""
|
| 666 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
| 667 |
+
<h4 style="margin:0">Sources</h4>
|
| 668 |
+
<span class="status-badge {status_color}">{status_text}</span>
|
| 669 |
+
</div>
|
| 670 |
+
""", unsafe_allow_html=True)
|
| 671 |
+
|
| 672 |
+
# Tabs
|
| 673 |
+
# Tabs Removed - Unified View
|
| 674 |
+
# tab_offline, tab_online = st.tabs(["Offline Sources", "Online Sources"])
|
| 675 |
|
| 676 |
+
# Helper to fetch sources
|
| 677 |
+
sources_list = []
|
| 678 |
+
try:
|
| 679 |
+
s_resp = requests.get(f"{API_URL}/sources")
|
| 680 |
+
if s_resp.status_code == 200:
|
| 681 |
+
sources_list = s_resp.json()
|
| 682 |
+
except:
|
| 683 |
+
pass
|
| 684 |
+
|
| 685 |
+
if sources_list:
|
| 686 |
+
for src in sources_list:
|
| 687 |
+
# Icon Logic
|
| 688 |
+
icon = "📄"
|
| 689 |
+
if src['type'] == 'url': icon = "🌐"
|
| 690 |
+
elif src['type'] == 'youtube': icon = "📺"
|
| 691 |
+
|
| 692 |
+
c1, c2 = st.columns([0.85, 0.15])
|
| 693 |
+
with c1:
|
| 694 |
+
st.markdown(f"""
|
| 695 |
+
<div class="source-item">
|
| 696 |
+
<span class="source-icon">{icon}</span>
|
| 697 |
+
<span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{src['filename']}</span>
|
| 698 |
+
</div>
|
| 699 |
+
""", unsafe_allow_html=True)
|
| 700 |
+
with c2:
|
| 701 |
+
if st.button("🗑️", key=f"del_{src['id']}", help="Delete source", type="tertiary"):
|
| 702 |
+
try:
|
| 703 |
+
# Optimistically update UI by removing from list or just rerun
|
| 704 |
+
requests.delete(f"{API_URL}/sources/{src['id']}")
|
| 705 |
+
time.sleep(0.1) # Small delay for DB prop
|
| 706 |
+
st.rerun()
|
| 707 |
+
except Exception as e:
|
| 708 |
+
st.error(f"Error: {e}")
|
| 709 |
+
else:
|
| 710 |
+
st.markdown("""
|
| 711 |
+
<div style="text-align: center; color: #9CA3AF; padding: 20px; font-size: 0.9rem;">
|
| 712 |
+
No sources added
|
| 713 |
</div>
|
| 714 |
""", unsafe_allow_html=True)
|
| 715 |
+
|
| 716 |
+
# --- Add Source Section ---
|
| 717 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
|
| 719 |
+
# PDF Upload
|
| 720 |
+
with st.expander("+ Add PDF / Document"):
|
| 721 |
uploaded = st.file_uploader("Upload PDF", type=["pdf", "txt"], label_visibility="collapsed")
|
| 722 |
if uploaded:
|
| 723 |
+
# Check duplication in session state to prevent infinite rerun loop
|
| 724 |
+
if "processed_files" not in st.session_state:
|
| 725 |
+
st.session_state.processed_files = set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 726 |
|
| 727 |
+
if uploaded.name not in st.session_state.processed_files:
|
| 728 |
+
try:
|
| 729 |
+
# Send to backend
|
| 730 |
+
files = {"file": (uploaded.name, uploaded, uploaded.type)}
|
| 731 |
+
with st.spinner("Uploading & Indexing..."):
|
| 732 |
+
resp = requests.post(f"{API_URL}/upload", files=files)
|
| 733 |
+
if resp.status_code == 200:
|
| 734 |
+
st.session_state.processed_files.add(uploaded.name)
|
| 735 |
+
st.success(f"Successfully uploaded: {uploaded.name}")
|
| 736 |
+
time.sleep(1)
|
| 737 |
+
st.rerun()
|
| 738 |
+
else:
|
| 739 |
+
st.error(f"Upload failed: {resp.text}")
|
| 740 |
+
except Exception as e:
|
| 741 |
+
st.error(f"Error: {e}")
|
| 742 |
|
| 743 |
+
# URL Input
|
| 744 |
+
with st.expander("+ Add URL / YouTube"):
|
| 745 |
+
url_input = st.text_input("URL", placeholder="https://youtube.com/...", label_visibility="collapsed")
|
| 746 |
+
if st.button("Process URL", use_container_width=True):
|
| 747 |
+
if not url_input:
|
| 748 |
+
st.warning("Please enter a URL")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
else:
|
| 750 |
+
with st.spinner("Fetching & Indexing..."):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
try:
|
| 752 |
+
resp = requests.post(f"{API_URL}/ingest_url", json={"url": url_input})
|
| 753 |
+
if resp.status_code == 200:
|
| 754 |
+
data = resp.json()
|
| 755 |
+
st.success(data["message"])
|
| 756 |
+
time.sleep(1)
|
| 757 |
+
st.rerun()
|
| 758 |
else:
|
| 759 |
+
st.error(f"Failed: {resp.text}")
|
| 760 |
except Exception as e:
|
| 761 |
+
st.error(f"Error: {e}")
|
| 762 |
+
|
| 763 |
+
# --- FOCUS MODE UI ---
|
| 764 |
+
if st.session_state.focus_mode:
|
| 765 |
+
# FOCUS: LEFT COLUMN (CHAT)
|
| 766 |
+
with left_col:
|
| 767 |
+
st.markdown("### 💬 Study Assistant")
|
| 768 |
+
# Reuse existing chat logic or a simplified version
|
| 769 |
+
messages = st.container(height=600)
|
| 770 |
+
with messages:
|
| 771 |
+
for msg in st.session_state.chat_history:
|
| 772 |
+
with st.chat_message(msg["role"]):
|
| 773 |
+
st.write(msg["content"])
|
| 774 |
+
|
| 775 |
+
# New Chat Input
|
| 776 |
+
if prompt := st.chat_input(f"Ask about {st.session_state.active_topic}..."):
|
| 777 |
+
st.session_state.chat_history.append({"role": "user", "content": prompt})
|
| 778 |
+
with st.chat_message("user"):
|
| 779 |
+
st.write(prompt)
|
| 780 |
+
|
| 781 |
+
# Call AI
|
| 782 |
+
with st.chat_message("assistant"):
|
| 783 |
+
with st.spinner("Thinking..."):
|
| 784 |
+
try:
|
| 785 |
+
# Prepare history
|
| 786 |
+
history = [{"role": m["role"], "content": m["content"]} for m in st.session_state.chat_history[:-1][-5:]]
|
| 787 |
+
resp = requests.post(f"{API_URL}/query", json={"question": prompt, "history": history})
|
| 788 |
+
if resp.status_code == 200:
|
| 789 |
+
data = resp.json()
|
| 790 |
+
ans = data.get("answer", "No answer.")
|
| 791 |
+
st.write(ans)
|
| 792 |
+
st.session_state.chat_history.append({"role": "assistant", "content": ans})
|
| 793 |
+
else:
|
| 794 |
+
st.error("Error.")
|
| 795 |
+
except Exception as e:
|
| 796 |
+
st.error(f"Connection Error: {e}")
|
| 797 |
+
|
| 798 |
+
# FOCUS: RIGHT COLUMN (CONTENT) - (Technically mid_col in layout)
|
| 799 |
+
with mid_col:
|
| 800 |
+
st.markdown(f"## 📖 {st.session_state.active_topic}")
|
| 801 |
+
st.info("Here is the learning material for this topic.")
|
| 802 |
+
|
| 803 |
+
# Placeholder Content
|
| 804 |
+
st.markdown("""
|
| 805 |
+
### Key Concepts
|
| 806 |
+
- **Concept 1:** Definition and importance.
|
| 807 |
+
- **Concept 2:** Real-world application.
|
| 808 |
+
- **Concept 3:** Detailed analysis.
|
| 809 |
+
""")
|
| 810 |
+
|
| 811 |
+
# Exit Button
|
| 812 |
+
st.markdown("---")
|
| 813 |
+
if st.button("⬅️ Exit Focus Mode", use_container_width=True):
|
| 814 |
+
st.session_state.focus_mode = False
|
| 815 |
+
st.session_state.active_topic = None
|
| 816 |
+
st.rerun()
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
# --- MIDDLE COLUMN: Intelligent Workspace ---
|
| 820 |
+
# --- MIDDLE COLUMN: Intelligent Workspace ---
|
| 821 |
+
if not st.session_state.focus_mode:
|
| 822 |
+
with mid_col:
|
| 823 |
+
# Header
|
| 824 |
+
h_col1, h_col2 = st.columns([0.8, 0.2])
|
| 825 |
+
with h_col1:
|
| 826 |
+
st.markdown("### Intelligent Workspace")
|
| 827 |
+
with h_col2:
|
| 828 |
+
if st.button("📊 Analytics"):
|
| 829 |
+
show_analytics_dialog()
|
| 830 |
+
|
| 831 |
+
# Reading Content / Chat Area
|
| 832 |
+
# Use native container with border to replace "custom-card" and fix "small box" issue
|
| 833 |
+
with st.container(border=True):
|
| 834 |
+
|
| 835 |
+
# 1. Chat History / Content (Scrollable Container)
|
| 836 |
+
# using height=500 to create a scrolling area like a real chat app
|
| 837 |
+
chat_container = st.container(height=500)
|
| 838 |
+
|
| 839 |
+
with chat_container:
|
| 840 |
+
if not st.session_state.chat_history:
|
| 841 |
+
# Welcome Content
|
| 842 |
+
st.markdown('<div class="article-title">Welcome to FocusFlow</div>', unsafe_allow_html=True)
|
| 843 |
+
st.markdown("""
|
| 844 |
+
<div class="article-text">
|
| 845 |
+
This is your intelligent workspace. <br>
|
| 846 |
+
Upload a PDF in the sources panel to get started, or ask a question below.
|
| 847 |
+
<br><br>
|
| 848 |
+
Your content will appear here...
|
| 849 |
+
</div>
|
| 850 |
+
""", unsafe_allow_html=True)
|
| 851 |
+
else:
|
| 852 |
+
# Chat Messages
|
| 853 |
+
# Chat Messages
|
| 854 |
+
for i, msg in enumerate(st.session_state.chat_history):
|
| 855 |
+
with st.chat_message(msg["role"]):
|
| 856 |
+
st.markdown(msg["content"])
|
| 857 |
+
|
| 858 |
+
# Source Display Logic (MUST BE INSIDE THE LOOP)
|
| 859 |
+
if msg["role"] == "assistant" and msg.get("sources"):
|
| 860 |
+
with st.expander("Sources used"):
|
| 861 |
+
for s in msg["sources"]:
|
| 862 |
+
# Crash Proof Check: Handle string vs dict
|
| 863 |
+
if isinstance(s, str):
|
| 864 |
+
st.info(f"📄 {s[:100]}...")
|
| 865 |
+
else:
|
| 866 |
+
# It is a dictionary
|
| 867 |
+
src = s.get("source", "Document")
|
| 868 |
+
page_num = s.get("page", 1)
|
| 869 |
+
label = f"📄 {src} | Page {page_num}"
|
| 870 |
+
st.caption(label)
|
| 871 |
+
# 2. Input Area (Pinned to bottom of the visible card by being outside scroll container)
|
| 872 |
+
with st.form(key="chat_form", clear_on_submit=True):
|
| 873 |
+
cols = st.columns([0.85, 0.15])
|
| 874 |
+
with cols[0]:
|
| 875 |
+
user_input = st.text_input("Ask a question...", placeholder="Ask a question about your documents...", label_visibility="collapsed", key="chat_input_widget")
|
| 876 |
+
with cols[1]:
|
| 877 |
+
submit_button = st.form_submit_button("Send", use_container_width=True)
|
| 878 |
|
| 879 |
+
if submit_button and user_input:
|
| 880 |
+
st.session_state.chat_history.append({"role": "user", "content": user_input})
|
| 881 |
+
|
| 882 |
+
try:
|
| 883 |
+
with st.spinner("Thinking..."):
|
| 884 |
+
# Prepare history (exclude sources for cleanliness)
|
| 885 |
+
history = [
|
| 886 |
+
{"role": msg["role"], "content": msg["content"]}
|
| 887 |
+
for msg in st.session_state.chat_history[:-1][-5:] # Last 5 valid history items before current question
|
| 888 |
+
]
|
| 889 |
+
|
| 890 |
+
resp = requests.post(f"{API_URL}/query", json={"question": user_input, "history": history})
|
| 891 |
+
if resp.status_code == 200:
|
| 892 |
+
try:
|
| 893 |
+
data = resp.json()
|
| 894 |
+
ans = data.get("answer", "No answer.")
|
| 895 |
+
srcs = data.get("sources", [])
|
| 896 |
+
if srcs:
|
| 897 |
+
st.session_state.chat_history.append({"role": "assistant", "content": ans, "sources": srcs})
|
| 898 |
+
else:
|
| 899 |
+
st.session_state.chat_history.append({"role": "assistant", "content": ans})
|
| 900 |
+
except Exception as e:
|
| 901 |
+
st.session_state.chat_history.append({"role": "assistant", "content": f"Error parsing response: {e}\\n\\nRaw text: {resp.text}"})
|
| 902 |
+
else:
|
| 903 |
+
st.session_state.chat_history.append({"role": "assistant", "content": "Error."})
|
| 904 |
+
except Exception as e:
|
| 905 |
+
st.session_state.chat_history.append({"role": "assistant", "content": f"Connection Error: {e}"})
|
| 906 |
+
|
| 907 |
+
st.rerun()
|
| 908 |
|
| 909 |
|
| 910 |
# --- RIGHT COLUMN: Scheduler ---
|
| 911 |
+
# --- RIGHT COLUMN: Scheduler ---
|
| 912 |
+
# --- RIGHT COLUMN: Scheduler ---
|
| 913 |
+
if right_col:
|
| 914 |
+
with right_col:
|
| 915 |
+
# Scheduler Header Removed to save space
|
| 916 |
+
# st.markdown("### Scheduler")
|
|
|
|
| 917 |
|
| 918 |
+
# Calendar Agent
|
| 919 |
+
# Calendar Agent (Minimalist)
|
| 920 |
+
# Removing st.container() wrapper to reduce vertical gap/white block
|
| 921 |
+
|
| 922 |
+
# Calculate Start Date: 1st of Previous Month
|
| 923 |
+
today = date.today()
|
| 924 |
+
# Logic to go back 1 month
|
| 925 |
+
last_month_year = today.year if today.month > 1 else today.year - 1
|
| 926 |
+
last_month = today.month - 1 if today.month > 1 else 12
|
| 927 |
+
start_date_str = f"{last_month_year}-{last_month:02d}-01"
|
| 928 |
+
|
| 929 |
calendar_options = {
|
| 930 |
+
# User requested arrows "on both sides"
|
| 931 |
+
"headerToolbar": {"left": "prev", "center": "title", "right": "next"},
|
| 932 |
+
|
| 933 |
+
"initialView": "multiMonthYear",
|
| 934 |
+
"initialDate": start_date_str,
|
| 935 |
+
"views": {
|
| 936 |
+
"multiMonthYear": {
|
| 937 |
+
"type": "multiMonthYear",
|
| 938 |
+
"duration": {"months": 3},
|
| 939 |
+
"multiMonthMaxColumns": 3,
|
| 940 |
+
# FIXED: 280px ensures text is readable. 100px was too small!
|
| 941 |
+
# This will force the container to scroll horizontally.
|
| 942 |
+
"multiMonthMinWidth": 280,
|
| 943 |
+
}
|
| 944 |
+
},
|
| 945 |
+
# JS Option to format title shorter (e.g. "Dec 2025 - Feb 2026")
|
| 946 |
+
"titleFormat": {"year": "numeric", "month": "short"},
|
| 947 |
+
# "contentHeight": "auto",
|
| 948 |
}
|
|
|
|
| 949 |
|
| 950 |
+
calendar(events=[], options=calendar_options, key="mini_cal")
|
| 951 |
|
| 952 |
+
# --- B. TALK TO CALENDAR (Fixed: No Loop) ---
|
| 953 |
+
with st.form("calendar_chat_form", clear_on_submit=True):
|
| 954 |
+
plan_query = st.text_input("Talk to Calendar...", placeholder="e.g., 'Make a 3 day plan'")
|
| 955 |
+
submitted = st.form_submit_button("🚀 Generate Plan")
|
| 956 |
|
| 957 |
+
if submitted and plan_query:
|
| 958 |
+
with st.spinner("🤖 AI (1B) is thinking..."):
|
| 959 |
+
try:
|
| 960 |
+
# Increased timeout to 300s for safety
|
| 961 |
+
resp = requests.post(f"{API_URL}/generate_plan", json={"request_text": plan_query}, timeout=300)
|
| 962 |
+
|
| 963 |
+
if resp.status_code == 200:
|
| 964 |
+
plan_data = resp.json()
|
| 965 |
+
raw_plan = plan_data.get("days", [])
|
| 966 |
+
|
| 967 |
+
# ROBUST SANITIZATION LOOP
|
| 968 |
+
for index, task in enumerate(raw_plan):
|
| 969 |
+
# 1. Fix Missing ID (Use index + 1 if missing)
|
| 970 |
+
if "id" not in task:
|
| 971 |
+
task["id"] = index + 1
|
| 972 |
+
|
| 973 |
+
# 2. Fix Missing Keys
|
| 974 |
+
task["quiz_passed"] = task.get("quiz_passed", False)
|
| 975 |
+
task["status"] = task.get("status", "locked" if task.get("locked", True) else "unlocked")
|
| 976 |
+
task["title"] = task.get("topic", f"Topic {task['id']}") # Fallback title
|
| 977 |
+
|
| 978 |
+
st.session_state.study_plan = raw_plan
|
| 979 |
+
st.success("📅 Plan Created! Check Today's Topics.")
|
| 980 |
+
st.rerun()
|
| 981 |
+
else:
|
| 982 |
+
st.error(f"Failed: {resp.text}")
|
| 983 |
+
except Exception as e:
|
| 984 |
+
st.error(f"Error: {e}")
|
| 985 |
+
# NO SPACER here
|
| 986 |
+
|
| 987 |
+
# Removed spacer to satisfy "remove white box" request
|
| 988 |
+
# st.markdown("<br>", unsafe_allow_html=True) # Spacer
|
| 989 |
+
|
| 990 |
+
# Today's Topics (Gamified)
|
| 991 |
+
# Merging the opening DIV and the Header into ONE markdown call to ensure they render together.
|
| 992 |
+
st.markdown("""
|
| 993 |
+
<div class="custom-card">
|
| 994 |
+
<div style="display:flex; justify-content:space-between; align-items:center;"><h4>Today's Topics</h4></div>
|
| 995 |
+
""", unsafe_allow_html=True)
|
| 996 |
|
| 997 |
+
if not st.session_state.study_plan:
|
| 998 |
+
# EMPTY STATE
|
| 999 |
+
st.info("Tell the calendar to make a plan 📅")
|
| 1000 |
+
else:
|
| 1001 |
+
# Render Plan
|
| 1002 |
+
st.markdown(f'<span style="font-size:0.8rem; color:#6B7280">{len([t for t in st.session_state.study_plan if t["quiz_passed"]])}/{len(st.session_state.study_plan)} Done</span>', unsafe_allow_html=True)
|
| 1003 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1004 |
|
| 1005 |
+
# Iterate through Mock Plan
|
| 1006 |
+
for i, topic in enumerate(st.session_state.study_plan):
|
| 1007 |
+
t_id = topic["id"]
|
| 1008 |
+
title = topic["title"]
|
| 1009 |
+
status = topic["status"]
|
| 1010 |
+
passed = topic["quiz_passed"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1011 |
|
| 1012 |
+
# Styles
|
| 1013 |
+
opacity = "1.0" if status == "unlocked" else "0.5"
|
| 1014 |
+
icon = "🔒" if status == "locked" else ("✅" if passed else "🟦")
|
| 1015 |
+
|
| 1016 |
+
# Card Container
|
| 1017 |
+
with st.container():
|
| 1018 |
+
c1, c2 = st.columns([0.15, 0.85])
|
| 1019 |
+
with c1:
|
| 1020 |
+
st.markdown(f"<div style='font-size:1.5rem; opacity:{opacity}'>{icon}</div>", unsafe_allow_html=True)
|
| 1021 |
+
with c2:
|
| 1022 |
+
# Title
|
| 1023 |
+
st.markdown(f"<div style='font-weight:600; opacity:{opacity}'>{title}</div>", unsafe_allow_html=True)
|
| 1024 |
+
|
| 1025 |
+
# Unlocked & Not Passed -> Show Actions
|
| 1026 |
+
if status == "unlocked" and not passed:
|
| 1027 |
+
# Dropdown / Expandable Area
|
| 1028 |
+
with st.expander("Start Learning", expanded=True):
|
| 1029 |
+
# FOCUS MODE TRIGGER
|
| 1030 |
+
if st.button("🚀 Enter Focus Mode", key=f"focus_{t_id}", use_container_width=True):
|
| 1031 |
+
st.session_state.focus_mode = True
|
| 1032 |
+
st.session_state.active_topic = title
|
| 1033 |
+
st.rerun()
|
| 1034 |
|
| 1035 |
+
st.info("Mastery Required: 80%")
|
| 1036 |
+
if st.button("Take Mandatory Quiz", key=f"q_{t_id}", type="primary", use_container_width=True):
|
| 1037 |
+
show_quiz_dialog(t_id, title)
|
| 1038 |
+
|
| 1039 |
+
if st.button("Flashcards (Optional)", key=f"fc_{t_id}", use_container_width=True):
|
| 1040 |
+
show_flashcard_dialog(t_id, title)
|
| 1041 |
+
|
| 1042 |
+
st.markdown("<hr style='margin: 10px 0;'>", unsafe_allow_html=True)
|
| 1043 |
+
|
| 1044 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
backend/main.py
CHANGED
|
@@ -67,7 +67,27 @@ async def upload_file(file: UploadFile = File(...), db: Session = Depends(get_db
|
|
| 67 |
db.refresh(new_source)
|
| 68 |
|
| 69 |
return {"message": "File uploaded and ingested successfully", "id": new_source.id}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
@app.get("/sources", response_model=List[SourceItem])
|
| 72 |
def get_sources(db: Session = Depends(get_db)):
|
| 73 |
sources = db.query(Source).filter(Source.is_active == True).all()
|
|
@@ -80,6 +100,12 @@ def delete_source(source_id: int, db: Session = Depends(get_db)):
|
|
| 80 |
raise HTTPException(status_code=404, detail="Source not found")
|
| 81 |
|
| 82 |
# Soft delete
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
source.is_active = False
|
| 84 |
db.commit()
|
| 85 |
return {"success": True, "message": "Source deleted"}
|
|
@@ -127,14 +153,26 @@ def unlock_topic(request: UnlockRequest, db: Session = Depends(get_db)):
|
|
| 127 |
next_unlocked = True
|
| 128 |
|
| 129 |
db.commit()
|
| 130 |
-
return {"success": True, "message": "Quiz
|
| 131 |
else:
|
| 132 |
db.commit()
|
| 133 |
-
return {"success":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
class QueryRequest(BaseModel):
|
| 136 |
question: str
|
| 137 |
-
history:
|
| 138 |
|
| 139 |
@app.post("/query")
|
| 140 |
async def query_kb(request: QueryRequest):
|
|
|
|
| 67 |
db.refresh(new_source)
|
| 68 |
|
| 69 |
return {"message": "File uploaded and ingested successfully", "id": new_source.id}
|
| 70 |
+
return {"message": "File uploaded and ingested successfully", "id": new_source.id}
|
| 71 |
+
|
| 72 |
+
class UrlRequest(BaseModel):
|
| 73 |
+
url: str
|
| 74 |
|
| 75 |
+
@app.post("/ingest_url")
|
| 76 |
+
def ingest_url_endpoint(request: UrlRequest, db: Session = Depends(get_db)):
|
| 77 |
+
try:
|
| 78 |
+
from backend.rag_engine import ingest_url
|
| 79 |
+
title = ingest_url(request.url)
|
| 80 |
+
|
| 81 |
+
# Save to DB
|
| 82 |
+
# We use the title as the filename for display purposes
|
| 83 |
+
new_source = Source(filename=title, type="url", file_path=request.url, is_active=True)
|
| 84 |
+
db.add(new_source)
|
| 85 |
+
db.commit()
|
| 86 |
+
db.refresh(new_source)
|
| 87 |
+
|
| 88 |
+
return {"message": f"Successfully added: {title}", "id": new_source.id}
|
| 89 |
+
except Exception as e:
|
| 90 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 91 |
@app.get("/sources", response_model=List[SourceItem])
|
| 92 |
def get_sources(db: Session = Depends(get_db)):
|
| 93 |
sources = db.query(Source).filter(Source.is_active == True).all()
|
|
|
|
| 100 |
raise HTTPException(status_code=404, detail="Source not found")
|
| 101 |
|
| 102 |
# Soft delete
|
| 103 |
+
try:
|
| 104 |
+
from backend.rag_engine import delete_document
|
| 105 |
+
delete_document(source.file_path)
|
| 106 |
+
except Exception as e:
|
| 107 |
+
print(f"Failed to delete from vector store: {e}")
|
| 108 |
+
|
| 109 |
source.is_active = False
|
| 110 |
db.commit()
|
| 111 |
return {"success": True, "message": "Source deleted"}
|
|
|
|
| 153 |
next_unlocked = True
|
| 154 |
|
| 155 |
db.commit()
|
| 156 |
+
return {"success": True, "message": "Quiz Passed! Next topic unlocked.", "next_topic_unlocked": next_unlocked}
|
| 157 |
else:
|
| 158 |
db.commit()
|
| 159 |
+
return {"success": False, "message": "Score too low. Try again!", "next_topic_unlocked": False}
|
| 160 |
+
|
| 161 |
+
class PlanRequest(BaseModel):
|
| 162 |
+
request_text: str
|
| 163 |
+
|
| 164 |
+
@app.post("/generate_plan")
|
| 165 |
+
def generate_plan_endpoint(request: PlanRequest):
|
| 166 |
+
try:
|
| 167 |
+
from backend.rag_engine import generate_study_plan
|
| 168 |
+
plan = generate_study_plan(request.request_text)
|
| 169 |
+
return plan
|
| 170 |
+
except Exception as e:
|
| 171 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 172 |
|
| 173 |
class QueryRequest(BaseModel):
|
| 174 |
question: str
|
| 175 |
+
history: List[dict] = []
|
| 176 |
|
| 177 |
@app.post("/query")
|
| 178 |
async def query_kb(request: QueryRequest):
|
backend/rag_engine.py
CHANGED
|
@@ -31,11 +31,79 @@ def ingest_document(file_path: str):
|
|
| 31 |
)
|
| 32 |
print(f"Ingested {len(splits)} chunks from {file_path}")
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
# In backend/rag_engine.py
|
| 35 |
|
| 36 |
def query_knowledge_base(question: str, history: list = []):
|
| 37 |
llm = Ollama(model="llama3.2:1b")
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
# --- PART 1: CONTEXT REWRITING (The Manual Fix) ---
|
| 40 |
standalone_question = question
|
| 41 |
if history:
|
|
@@ -77,17 +145,123 @@ def query_knowledge_base(question: str, history: list = []):
|
|
| 77 |
"sources": []
|
| 78 |
}
|
| 79 |
|
| 80 |
-
# --- PART 3: SEARCH & ANSWER ---
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
-
|
| 85 |
-
# Ensure you pass 'standalone_question' to your answer chain, not the raw 'question'
|
| 86 |
|
| 87 |
-
# .
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
-
#
|
| 90 |
-
final_prompt = f"Context: {docs}\n\nQuestion: {standalone_question}\n\nAnswer:"
|
| 91 |
answer = llm.invoke(final_prompt)
|
| 92 |
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
)
|
| 32 |
print(f"Ingested {len(splits)} chunks from {file_path}")
|
| 33 |
|
| 34 |
+
def ingest_url(url: str):
|
| 35 |
+
"""
|
| 36 |
+
Ingests content from a URL (YouTube or Web).
|
| 37 |
+
"""
|
| 38 |
+
from langchain_community.document_loaders import YoutubeLoader, WebBaseLoader
|
| 39 |
+
|
| 40 |
+
docs = []
|
| 41 |
+
try:
|
| 42 |
+
if "youtube.com" in url or "youtu.be" in url:
|
| 43 |
+
print(f"Loading YouTube Video: {url}")
|
| 44 |
+
try:
|
| 45 |
+
# Try with metadata first (requires pytube, often flaky)
|
| 46 |
+
loader = YoutubeLoader.from_youtube_url(url, add_video_info=True)
|
| 47 |
+
docs = loader.load()
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"⚠️ Metadata fetch failed ({e}). Retrying with transcript only...")
|
| 50 |
+
# Fallback: Transcript only (no title/author)
|
| 51 |
+
loader = YoutubeLoader.from_youtube_url(url, add_video_info=False)
|
| 52 |
+
docs = loader.load()
|
| 53 |
+
else:
|
| 54 |
+
print(f"Loading Website: {url}")
|
| 55 |
+
loader = WebBaseLoader(url)
|
| 56 |
+
docs = loader.load()
|
| 57 |
+
|
| 58 |
+
# Generic processing
|
| 59 |
+
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
|
| 60 |
+
splits = splitter.split_documents(docs)
|
| 61 |
+
|
| 62 |
+
if not splits:
|
| 63 |
+
raise ValueError("No content found to ingest")
|
| 64 |
+
|
| 65 |
+
# Store in ChromaDB
|
| 66 |
+
Chroma.from_documents(
|
| 67 |
+
documents=splits,
|
| 68 |
+
embedding=OllamaEmbeddings(model="nomic-embed-text"),
|
| 69 |
+
persist_directory=CACHE_DIR
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
title = docs[0].metadata.get("title", url) if docs else url
|
| 73 |
+
print(f"Ingested {len(splits)} chunks from {title}")
|
| 74 |
+
return title
|
| 75 |
+
|
| 76 |
+
except Exception as e:
|
| 77 |
+
print(f"Error ingesting URL: {e}")
|
| 78 |
+
raise e
|
| 79 |
+
|
| 80 |
+
def delete_document(source_path: str):
|
| 81 |
+
"""
|
| 82 |
+
Removes a document from the vector database by its source path.
|
| 83 |
+
"""
|
| 84 |
+
vector_store = Chroma(
|
| 85 |
+
persist_directory=CACHE_DIR,
|
| 86 |
+
embedding_function=OllamaEmbeddings(model="nomic-embed-text")
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# Delete based on metadata 'source'
|
| 90 |
+
try:
|
| 91 |
+
# Accessing the underlying chroma collection to delete by metadata
|
| 92 |
+
vector_store._collection.delete(where={"source": source_path})
|
| 93 |
+
print(f"Deleted vectors for source: {source_path}")
|
| 94 |
+
except Exception as e:
|
| 95 |
+
print(f"Error deleting from ChromaDB: {e}")
|
| 96 |
+
|
| 97 |
# In backend/rag_engine.py
|
| 98 |
|
| 99 |
def query_knowledge_base(question: str, history: list = []):
|
| 100 |
llm = Ollama(model="llama3.2:1b")
|
| 101 |
|
| 102 |
+
vector_store = Chroma(
|
| 103 |
+
persist_directory=CACHE_DIR,
|
| 104 |
+
embedding_function=OllamaEmbeddings(model="nomic-embed-text")
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
# --- PART 1: CONTEXT REWRITING (The Manual Fix) ---
|
| 108 |
standalone_question = question
|
| 109 |
if history:
|
|
|
|
| 145 |
"sources": []
|
| 146 |
}
|
| 147 |
|
| 148 |
+
# --- PART 3: SEARCH & ANSWER (Tutor Mode) ---
|
| 149 |
+
|
| 150 |
+
# 1. Search the PDF (Increased k=5 and added debug)
|
| 151 |
+
# 1. Search the PDF (Increased k=6 and added debug)
|
| 152 |
+
docs = vector_store.similarity_search(standalone_question, k=6)
|
| 153 |
+
print(f"🔎 Found {len(docs)} relevant chunks")
|
| 154 |
+
|
| 155 |
+
# Construct context with explicit Source Labels
|
| 156 |
+
context_parts = []
|
| 157 |
+
for doc in docs:
|
| 158 |
+
# Get a clean source name (e.g., "DSA.pdf" or "Video Title")
|
| 159 |
+
src = doc.metadata.get("title") or doc.metadata.get("source", "Unknown").split("/")[-1]
|
| 160 |
+
context_parts.append(f"SOURCE: {src}\nCONTENT: {doc.page_content}")
|
| 161 |
|
| 162 |
+
context_text = "\n\n---\n\n".join(context_parts)
|
|
|
|
| 163 |
|
| 164 |
+
# 2. The "Tutor Persona" Prompt
|
| 165 |
+
final_prompt = f"""
|
| 166 |
+
You are FocusFlow, a friendly and expert AI Tutor.
|
| 167 |
+
Your goal is to explain concepts from the provided PDF content clearly and simply.
|
| 168 |
+
|
| 169 |
+
GUIDELINES:
|
| 170 |
+
- Tone: Encouraging, professional, and educational.
|
| 171 |
+
- Format: Use **Bold** for key terms and Bullet points for lists.
|
| 172 |
+
- Strategy: Don't just copy the text. Read the context, understand it, and explain it to the student.
|
| 173 |
+
- If the context lists problems (like DSA), summarize the types of problems found.
|
| 174 |
+
- Source Check: The context now includes 'SOURCE:' labels. If the user asks about a specific file (like 'the PDF' or 'the Video'), ONLY use information from that specific source.
|
| 175 |
+
|
| 176 |
+
CONTEXT FROM PDF:
|
| 177 |
+
{context_text}
|
| 178 |
+
|
| 179 |
+
STUDENT'S QUESTION:
|
| 180 |
+
{standalone_question}
|
| 181 |
+
|
| 182 |
+
YOUR LESSON:
|
| 183 |
+
"""
|
| 184 |
|
| 185 |
+
# 3. Generate Answer
|
|
|
|
| 186 |
answer = llm.invoke(final_prompt)
|
| 187 |
|
| 188 |
+
# 4. Smart Source Formatting
|
| 189 |
+
sources_list = []
|
| 190 |
+
for doc in docs:
|
| 191 |
+
# Check if it's a Video (YoutubeLoader adds 'title')
|
| 192 |
+
if "title" in doc.metadata:
|
| 193 |
+
source_label = f"📺 {doc.metadata['title']}"
|
| 194 |
+
loc_label = "Transcript"
|
| 195 |
+
else:
|
| 196 |
+
# Fallback for PDFs
|
| 197 |
+
source_label = doc.metadata.get("source", "Unknown").split("/")[-1]
|
| 198 |
+
loc_label = f"Page {doc.metadata.get('page', 0) + 1}"
|
| 199 |
+
|
| 200 |
+
sources_list.append({
|
| 201 |
+
"source": source_label,
|
| 202 |
+
"location": loc_label
|
| 203 |
+
})
|
| 204 |
+
|
| 205 |
+
return {
|
| 206 |
+
"answer": answer,
|
| 207 |
+
"sources": sources_list
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
def generate_study_plan(user_request: str) -> dict:
|
| 211 |
+
print(f"🚀 STARTING PLAN GENERATION for: {user_request}")
|
| 212 |
+
import json
|
| 213 |
+
import time
|
| 214 |
+
|
| 215 |
+
# 1. Setup Retrieval & LLM
|
| 216 |
+
vector_store = Chroma(
|
| 217 |
+
persist_directory=CACHE_DIR,
|
| 218 |
+
embedding_function=OllamaEmbeddings(model="nomic-embed-text")
|
| 219 |
+
)
|
| 220 |
+
llm = Ollama(model="llama3.2:1b")
|
| 221 |
+
|
| 222 |
+
# --- 1. THE BACKUP PLAN (Guaranteed to work) ---
|
| 223 |
+
backup_plan = {
|
| 224 |
+
"days": [
|
| 225 |
+
{"id": 1, "day": 1, "topic": "Fundamentals of the Subject", "details": "Core definitions and basic laws.", "locked": False, "quiz_passed": False},
|
| 226 |
+
{"id": 2, "day": 2, "topic": "Advanced Theories", "details": "Applying the laws to complex systems.", "locked": True, "quiz_passed": False},
|
| 227 |
+
{"id": 3, "day": 3, "topic": "Practical Applications", "details": "Real-world case studies and problems.", "locked": True, "quiz_passed": False}
|
| 228 |
+
]
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
# --- 2. TRY THE AI ---
|
| 232 |
+
try:
|
| 233 |
+
# Limit context to be very fast
|
| 234 |
+
docs = vector_store.similarity_search("Syllabus topics", k=2)
|
| 235 |
+
if not docs:
|
| 236 |
+
context_text = "General syllabus topics."
|
| 237 |
+
else:
|
| 238 |
+
context_text = "\n".join([d.page_content[:200] for d in docs])
|
| 239 |
+
|
| 240 |
+
prompt = f"""
|
| 241 |
+
Context: {context_text}
|
| 242 |
+
Task: Create a 3-day study plan (JSON).
|
| 243 |
+
Format: {{"days": [{{"id": 1, "day": 1, "topic": "...", "details": "...", "locked": false}}]}}
|
| 244 |
+
Output JSON only.
|
| 245 |
+
"""
|
| 246 |
+
|
| 247 |
+
print("🤖 Asking AI (with 15s timeout expectation)...")
|
| 248 |
+
# In a real production app we would wrap this in a thread timeout,
|
| 249 |
+
# but for now we rely on the try/except block catching format errors.
|
| 250 |
+
raw_output = llm.invoke(prompt)
|
| 251 |
+
print("✅ AI Responded.")
|
| 252 |
+
|
| 253 |
+
# Clean & Parse
|
| 254 |
+
clean_json = raw_output.replace("```json", "").replace("```", "").strip()
|
| 255 |
+
plan = json.loads(clean_json)
|
| 256 |
+
|
| 257 |
+
# Validate Keys (The "Sanitizer")
|
| 258 |
+
for i, task in enumerate(plan.get("days", [])):
|
| 259 |
+
if "id" not in task: task["id"] = i + 1
|
| 260 |
+
if "topic" not in task: task["topic"] = f"Day {i+1} Topic"
|
| 261 |
+
task["quiz_passed"] = False
|
| 262 |
+
|
| 263 |
+
return plan
|
| 264 |
+
|
| 265 |
+
except Exception as e:
|
| 266 |
+
print(f"⚠️ AI FAILED ({e}). SWITCHING TO BACKUP PLAN.")
|
| 267 |
+
return backup_plan
|