File size: 12,215 Bytes
fb70f81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
from __future__ import annotations
import gradio as gr
from config.settings import SETTINGS
from services.model_router import ModelRouter
from quiz.models import QuizSession
from quiz.engine import QuizEngine
from storage.local_db import DB
from storage.mastery import MasteryStore
from agents.orchestrator import LearningOrchestrator
from ui.theme import build_champ_theme, CHAMP_CSS

print("[StudyWithChampAI] Initializing...")
_db = DB(SETTINGS.db_path)
_mastery_store = MasteryStore(_db)
_router = ModelRouter(
    ocr_model=SETTINGS.ocr_model_id,
    reasoning_model=SETTINGS.reasoning_model_id,
    multilingual_model=SETTINGS.multilingual_model_id,
    speech_model=SETTINGS.speech_model_id,
    hf_api_key=SETTINGS.hf_api_key,
    featherless_api_key=SETTINGS.featherless_api_key,
    max_tokens=SETTINGS.max_new_tokens,
    temperature=SETTINGS.temperature,
)
_orchestrator = LearningOrchestrator(
    _router, _db, _mastery_store, questions_per_quest=SETTINGS.questions_per_quest)
_engine = QuizEngine()
_QUEST_CACHE: list = []
_SOURCE_TEXT_CACHE: str = ""  # retained for revision quest generation
print("[StudyWithChampAI] Ready.")


def handle_process(file_obj, pasted_text: str, audio_obj, language: str):
    global _QUEST_CACHE, _SOURCE_TEXT_CACHE
    yield "Processing...", []
    try:
        quests = None
        if audio_obj is not None:
            yield "Transcribing voice input with Whisper...", []
            with open(audio_obj, "rb") as f:
                audio_bytes = f.read()
            quests = _orchestrator.process_voice(audio_bytes, language)
        elif file_obj is not None:
            yield "Processing file with MiniCPM-V...", []
            quests = _orchestrator.process_file(file_obj.name, language)
        elif pasted_text and pasted_text.strip():
            _SOURCE_TEXT_CACHE = pasted_text.strip()
            yield "Extracting concepts with MiniCPM-V...", []
            quests = _orchestrator.process_text(pasted_text.strip(), language)
        else:
            yield "No input provided. Upload a file, paste text, or record audio.", []; return

        _QUEST_CACHE = quests
        count = sum(len(q.questions) for q in quests)
        yield (f"Generated {len(quests)} quests with {count} questions "
               f"({count - len(quests)} regular + {len(quests)} boss battles). Go to **Quest Map**!",
               [q.name for q in quests])
    except Exception as exc:
        yield f"Error: {exc}", []


def handle_select_quest(quest_name: str):
    quest = next((q for q in _QUEST_CACHE if q.name == quest_name), None)
    if quest is None:
        return None, "Quest not found."
    session = QuizSession(quest_name=quest.name)
    session.questions = quest.questions
    return session, f"Quest '{quest_name}' ready! Go to Battle Mode."


def handle_answer(selected_option: str, session: QuizSession, question):
    if session is None or question is None:
        return "No active session.", "", "", "", "", gr.update(visible=False), gr.update(visible=True), session
    selected_idx = question.options.index(selected_option) if selected_option in question.options else -1
    result = _engine.submit_next(session, selected_idx)
    tutor_hint = ""
    if not result.is_correct:
        tutor_hint = _orchestrator.get_tutor_hint(question, selected_option)
    boss_label = "πŸ‘‘ BOSS BATTLE! " if result.was_boss else ""
    return (
        f"{boss_label}{'βœ… CORRECT!' if result.is_correct else '❌ WRONG!'}",
        f"+{result.xp_delta} XP" if result.xp_delta > 0 else "",
        result.streak_label,
        question.explanation,
        tutor_hint,
        gr.update(visible=True),
        gr.update(visible=False),
        session,
    )


def handle_translate(hint_text: str, target_lang: str):
    if not hint_text:
        return "_No hint to translate._"
    return _orchestrator.translate_hint(hint_text, target_lang)


def handle_session_complete(session: QuizSession):
    if session is None:
        return "No session.", "", "", "", ""
    result = _orchestrator.complete_quest(session)
    all_mastery = _mastery_store.all_mastery()
    mastery_lines = "\n".join(
        f"- **{t}**: {'β–ˆ' * int(v * 10)}{'β–‘' * (10 - int(v * 10))} {int(v*100)}%"
        for t, v in all_mastery.items())
    weak = result.get("weak_topics", [])
    weak_lines = ("\n".join(f"- {t} β€” revision needed" for t in weak)
                  if weak else "_All topics strong!_ πŸ†")
    from quiz.scoring import compute_grade
    grade = compute_grade(session.score, len(session.questions))
    return (f"### {session.score}/{len(session.questions)} correct",
            f"### Grade: {grade}", f"### XP Earned: {session.xp_earned}",
            mastery_lines or "_No mastery data yet._", weak_lines)


def handle_revision_quest(session: QuizSession):
    """Generate an adaptive revision quest for weak topics and add it to the cache."""
    global _QUEST_CACHE
    weak = _mastery_store.weak_topics()
    if not weak:
        return "No weak topics found β€” all strong!", [q.name for q in _QUEST_CACHE]
    try:
        revision = _orchestrator.generate_revision_quest(weak, source_text=_SOURCE_TEXT_CACHE)
        _QUEST_CACHE.append(revision)
        return (f"Revision quest unlocked: **{revision.name}**\nGo to Quest Map!",
                [q.name for q in _QUEST_CACHE])
    except Exception as exc:
        return f"Error generating revision quest: {exc}", [q.name for q in _QUEST_CACHE]


def build_app() -> gr.Blocks:
    with gr.Blocks(theme=build_champ_theme(), css=CHAMP_CSS, title="StudyWithChampAI") as demo:
        gr.Markdown("# StudyWithChampAI\n### Turn notes into quests. Turn studying into progression.")

        quest_names_state = gr.State([])
        session_state = gr.State(None)
        current_q_state = gr.State(None)
        q_idx_state = gr.State(0)

        with gr.Tab("Import Material"):
            gr.Markdown("### Upload PDF, image, voice, or paste text.\nMiniCPM-V reads and understands your material.")
            with gr.Row():
                file_input = gr.File(label="Upload File", file_types=[".pdf",".txt",".png",".jpg",".jpeg"])
                audio_input = gr.Audio(label="Record Voice", type="filepath", sources=["microphone"])
            text_input = gr.Textbox(label="Or paste notes here", lines=6, placeholder="Paste study material...")
            lang_dropdown = gr.Dropdown(choices=["English","Hindi","Bengali","Spanish","French"],
                                        value="English", label="Quiz Language")
            process_btn = gr.Button("Generate Quests βš”οΈ", variant="primary", size="lg")
            status_md = gr.Markdown("_Ready._")
            process_btn.click(fn=handle_process,
                              inputs=[file_input, text_input, audio_input, lang_dropdown],
                              outputs=[status_md, quest_names_state])

        with gr.Tab("Quest Map"):
            gr.Markdown("### Select a quest to begin.")
            quest_radio = gr.Radio(choices=[], label="Available Quests", interactive=True)
            start_btn = gr.Button("Enter Battle βš”οΈ", variant="primary")
            quest_status_md = gr.Markdown("")
            quest_names_state.change(fn=lambda n: gr.update(choices=n, visible=bool(n)),
                                     inputs=[quest_names_state], outputs=[quest_radio])
            start_btn.click(fn=handle_select_quest, inputs=[quest_radio],
                            outputs=[session_state, quest_status_md])

        with gr.Tab("Battle Mode"):
            gr.Markdown("### Answer to earn XP! Boss battle always awaits at quest end.")
            question_md = gr.Markdown("_Select a quest first._")
            progress_md = gr.Markdown("")
            answer_radio = gr.Radio(choices=[], label="Your Answer", interactive=True)
            submit_btn = gr.Button("Submit Answer", variant="primary")

            with gr.Group(visible=False) as feedback_group:
                result_md = gr.Markdown("")
                xp_md = gr.Markdown("")
                streak_md = gr.Markdown("")
                explanation_md = gr.Markdown("")
                tutor_md = gr.Markdown("")
                with gr.Row():
                    translate_lang = gr.Dropdown(choices=["Hindi","Bengali","Spanish","French"],
                                                 value="Hindi", label="Translate hint to", scale=1)
                    translate_btn = gr.Button("Translate 🌐", scale=1)
                translated_md = gr.Markdown("")
                next_btn = gr.Button("Next Question β†’")

            def load_question(session, idx):
                if session is None:
                    return "_No active session._", "", gr.update(choices=[], visible=False), gr.update(visible=False), None, idx
                if idx >= len(session.questions):
                    return "_Quest complete! Go to Results tab._", "", gr.update(choices=[], visible=False), gr.update(visible=False), None, idx
                q = session.questions[idx]
                header = "πŸ‘‘ **BOSS BATTLE** πŸ‘‘\n\n" if q.is_boss else ""
                return (f"{header}**Q{idx+1}/{len(session.questions)}**\n\n{q.text}",
                        f"Progress: {idx}/{len(session.questions)} | XP: {session.xp_earned}",
                        gr.update(choices=q.options, visible=True, value=None),
                        gr.update(visible=True), q, idx)

            session_state.change(fn=load_question, inputs=[session_state, q_idx_state],
                                 outputs=[question_md, progress_md, answer_radio, submit_btn, current_q_state, q_idx_state])
            submit_btn.click(fn=handle_answer, inputs=[answer_radio, session_state, current_q_state],
                             outputs=[result_md, xp_md, streak_md, explanation_md, tutor_md,
                                      feedback_group, submit_btn, session_state])
            translate_btn.click(fn=handle_translate, inputs=[tutor_md, translate_lang],
                                outputs=[translated_md])

            def advance_question(session, idx):
                new_idx = idx + 1
                if session is None or new_idx >= len(session.questions):
                    return ("_Quest complete! Go to Results._", "",
                            gr.update(choices=[], visible=False), gr.update(visible=False),
                            gr.update(visible=False), None, new_idx)
                q = session.questions[new_idx]
                header = "πŸ‘‘ **BOSS BATTLE** πŸ‘‘\n\n" if q.is_boss else ""
                return (f"{header}**Q{new_idx+1}/{len(session.questions)}**\n\n{q.text}",
                        f"Progress: {new_idx}/{len(session.questions)} | XP: {session.xp_earned}",
                        gr.update(choices=q.options, visible=True, value=None),
                        gr.update(visible=True), gr.update(visible=False), q, new_idx)

            next_btn.click(fn=advance_question, inputs=[session_state, q_idx_state],
                           outputs=[question_md, progress_md, answer_radio, submit_btn,
                                    feedback_group, current_q_state, q_idx_state])

        with gr.Tab("Results"):
            gr.Markdown("## Quest Complete! πŸ†")
            score_md = gr.Markdown("")
            grade_md = gr.Markdown("")
            xp_total_md = gr.Markdown("")
            mastery_md = gr.Markdown("### Mastery Map")
            weak_md = gr.Markdown("")
            with gr.Row():
                finish_btn = gr.Button("View Results", variant="primary")
                revision_btn = gr.Button("βš”οΈ Generate Revision Quest", variant="secondary")
            revision_status_md = gr.Markdown("")
            finish_btn.click(fn=handle_session_complete, inputs=[session_state],
                             outputs=[score_md, grade_md, xp_total_md, mastery_md, weak_md])
            revision_btn.click(fn=handle_revision_quest, inputs=[session_state],
                               outputs=[revision_status_md, quest_names_state])

    return demo


if __name__ == "__main__":
    app = build_app()
    app.launch(share=False, server_name="0.0.0.0", server_port=7860)