| """ |
| NWFWO Practice β Gradio MCP app for ChatGPT |
| |
| This file does two things: |
| 1) Runs a normal Gradio web app (for debugging in your browser) |
| 2) Exposes an MCP server + HTML UI card for ChatGPT Apps |
| |
| MVP behaviour: |
| - ChatGPT passes in: |
| - transliteration: foreign sentence (clue) |
| - correct_nwfwo: natural translation in native language (clue) |
| - explanation: literal native sentence that follows the foreign info flow (correct order) |
| - user_guess: learner's attempt at ordering the native words to match that flow |
| - This tool compares the word order and returns feedback + data for the widget. |
| """ |
|
|
| from dataclasses import dataclass |
| from typing import Optional, List |
|
|
| import gradio as gr |
|
|
|
|
| |
|
|
| @dataclass |
| class NWFWOExample: |
| |
| foreign_original: str |
| |
| native_natural: str |
| |
| native_literal_from_foreign: str |
| |
| explanation: Optional[str] = None |
|
|
|
|
| |
|
|
| @gr.mcp.tool( |
| _meta={ |
| |
| "openai/outputTemplate": "ui://widget/nwfwo-practice-card.html", |
| "openai/resultCanProduceWidget": True, |
| "openai/widgetAccessible": True, |
| } |
| ) |
| def check_nwfwo( |
| transliteration: str, |
| correct_nwfwo: str, |
| user_guess: str, |
| explanation: Optional[str] = None, |
| ): |
| """ |
| Word-order practice for listening support. |
| |
| Parameters (as ChatGPT should use them): |
| |
| - transliteration: |
| foreign sentence (A1), shown as a clue. |
| - correct_nwfwo: |
| natural translation in the learner's native language (B1), shown as a clue. |
| - explanation: |
| literal native sentence that follows the foreign word order (A2). |
| This is the CORRECT target order the learner should try to match. |
| - user_guess: |
| learner's attempt at ordering the native words to match that foreign flow. |
| |
| Behaviour: |
| - Split the correct literal sentence and user guess into tokens. |
| - Compare position-by-position. |
| - Return counts and per-position matches plus the sentences for display. |
| """ |
|
|
| |
| foreign_original = transliteration or "" |
| native_natural = correct_nwfwo or "" |
| native_literal_from_foreign = explanation or "" |
| user_order_native_literal = user_guess or "" |
|
|
| |
| def tokenize(s: str) -> List[str]: |
| |
| return [t for t in s.strip().split() if t] |
|
|
| correct_tokens = tokenize(native_literal_from_foreign) |
| user_tokens = tokenize(user_order_native_literal) |
|
|
| max_len = max(len(correct_tokens), len(user_tokens)) if correct_tokens or user_tokens else 0 |
|
|
| matches_by_index: List[bool] = [] |
| for i in range(max_len): |
| c = correct_tokens[i] if i < len(correct_tokens) else None |
| u = user_tokens[i] if i < len(user_tokens) else None |
| matches_by_index.append(c is not None and u is not None and c.lower() == u.lower()) |
|
|
| num_correct = sum(1 for m in matches_by_index if m) |
| total_positions = len(correct_tokens) |
|
|
| if total_positions == 0: |
| feedback = ( |
| "I couldn't see any words in the 'correct literal' sentence. " |
| "Make sure explanation contains the literal native sentence to follow." |
| ) |
| else: |
| if num_correct == total_positions: |
| feedback = ( |
| f"β
Great! All {total_positions} positions match the foreign information flow." |
| ) |
| elif num_correct == 0: |
| feedback = ( |
| "β None of the positions match the foreign flow yet. " |
| "Try lining up the verb and time/place words with how they appear in the foreign sentence." |
| ) |
| else: |
| feedback = ( |
| f"β οΈ You matched {num_correct} out of {total_positions} positions. " |
| "Look at which words moved and how that changes the flow." |
| ) |
|
|
| result = { |
| |
| "transliteration": foreign_original, |
| "correct_nwfwo": native_natural, |
| "user_guess": user_order_native_literal, |
| "explanation": native_literal_from_foreign, |
| |
| "foreign_original": foreign_original, |
| "native_natural": native_natural, |
| "native_literal_from_foreign": native_literal_from_foreign, |
| "correct_tokens": correct_tokens, |
| "user_tokens": user_tokens, |
| "matches_by_index": matches_by_index, |
| "num_correct": num_correct, |
| "total_positions": total_positions, |
| "feedback": feedback, |
| } |
|
|
| return result |
|
|
|
|
| |
|
|
| def local_check_ui(foreign_sentence, native_natural, user_order, native_literal): |
| res = check_nwfwo( |
| transliteration=foreign_sentence, |
| correct_nwfwo=native_natural, |
| user_guess=user_order, |
| explanation=native_literal, |
| ) |
|
|
| |
| lines = [ |
| f"Foreign sentence: {res['foreign_original']}", |
| f"Translation (clue): {res['native_natural']}", |
| "", |
| f"Correct foreign flow in native words: {res['native_literal_from_foreign']}", |
| f"Your order: {' '.join(res['user_tokens'])}", |
| "", |
| f"Positions correct: {res['num_correct']} / {res['total_positions']}", |
| "", |
| f"Feedback: {res['feedback']}", |
| ] |
| return "\n".join(lines) |
|
|
|
|
| with gr.Blocks() as demo: |
| gr.Markdown("## Word Order Practice β Local Debug UI") |
|
|
| with gr.Row(): |
| with gr.Column(): |
| foreign_box = gr.Textbox( |
| label="Foreign sentence (clue)", |
| value="Tomorrow together rice want-to-eat?", |
| ) |
| native_natural_box = gr.Textbox( |
| label="Natural translation in your language (clue)", |
| value="Do you want to eat together tomorrow?", |
| ) |
| native_literal_box = gr.Textbox( |
| label="Correct literal native sentence (foreign info flow)", |
| value="Tomorrow together rice want-to-eat?", |
| ) |
| user_order_box = gr.Textbox( |
| label="Your ordered native sentence (try to follow foreign flow)", |
| value="Tomorrow rice want-to-eat together?", |
| ) |
| btn = gr.Button("Check order") |
|
|
| with gr.Column(): |
| result_box = gr.Textbox( |
| label="Result", |
| lines=10, |
| ) |
|
|
| btn.click( |
| local_check_ui, |
| inputs=[ |
| foreign_box, |
| native_natural_box, |
| user_order_box, |
| native_literal_box, |
| ], |
| outputs=[result_box], |
| ) |
|
|
| |
| |
| widget_preview = gr.Code( |
| label="NWFWO widget HTML (for MCP)", |
| language="html", |
| visible=False, |
| ) |
| |
| demo.load(fn=lambda: nwfwo_html_card(), inputs=None, outputs=widget_preview) |
|
|
|
|
| |
|
|
| @gr.mcp.resource( |
| "ui://widget/nwfwo-practice-card.html", |
| mime_type="text/html+skybridge", |
| ) |
| def nwfwo_html_card(): |
| """ |
| This HTML will appear as a card inside ChatGPT when this tool runs. |
| |
| It can read: |
| - window.openai.toolInput (what ChatGPT sent into the tool) |
| - window.openai.toolOutput (what our Python tool returned) |
| """ |
| html = r""" |
| <div id="nwfwo-card-root"></div> |
| <script> |
| const root = document.getElementById("nwfwo-card-root"); |
| |
| function render() { |
| const input = (window.openai && window.openai.toolInput) || {}; |
| const output = (window.openai && window.openai.toolOutput) || {}; |
| |
| const foreignSentence = input.transliteration |
| || output.foreign_original |
| || output.transliteration |
| || "Foreign sentence goes here"; |
| |
| const nativeNatural = input.correct_nwfwo |
| || output.native_natural |
| || "Natural translation goes here"; |
| |
| const nativeLiteral = output.native_literal_from_foreign |
| || input.explanation |
| || output.explanation |
| || "Literal native sentence (foreign flow)"; |
| |
| const userOrder = output.user_tokens && output.user_tokens.length |
| ? output.user_tokens.join(" ") |
| : (input.user_guess || output.user_guess || ""); |
| |
| const correctTokens = output.correct_tokens || []; |
| const userTokens = output.user_tokens || []; |
| const matches = output.matches_by_index || []; |
| const numCorrect = output.num_correct ?? 0; |
| const total = output.total_positions ?? correctTokens.length; |
| |
| const rows = []; |
| const maxLen = Math.max(correctTokens.length, userTokens.length); |
| for (let i = 0; i < maxLen; i++) { |
| const c = correctTokens[i] ?? ""; |
| const u = userTokens[i] ?? ""; |
| const m = matches[i]; |
| |
| rows.push(` |
| <tr> |
| <td style="padding:4px 8px; font-size:13px;">${i + 1}</td> |
| <td style="padding:4px 8px; font-size:13px;">${c}</td> |
| <td style="padding:4px 8px; font-size:13px;">${u}</td> |
| <td style="padding:4px 8px; font-size:13px;"> |
| ${m === true ? "β
" : (u ? "β" : "")} |
| </td> |
| </tr> |
| `); |
| } |
| |
| const summaryText = total > 0 |
| ? `${numCorrect} / ${total} positions match the foreign information flow.` |
| : "No positions to compare yet β check the literal sentence input."; |
| |
| root.innerHTML = ` |
| <div style=" |
| border-radius:16px; |
| border:1px solid #ddd; |
| padding:16px; |
| font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; |
| max-width: 640px; |
| "> |
| <div style="font-size:14px;color:#666;margin-bottom:4px;"> |
| Word Order Practice β follow the foreign information flow |
| </div> |
| |
| <div style="margin-bottom:12px;"> |
| <div style="font-size:13px;color:#666;">Foreign sentence</div> |
| <div style="font-size:17px;font-weight:600;"> |
| ${foreignSentence} |
| </div> |
| </div> |
| |
| <div style="margin-bottom:12px;"> |
| <div style="font-size:13px;color:#666;">Translation (clue)</div> |
| <div style="font-size:15px;"> |
| ${nativeNatural} |
| </div> |
| </div> |
| |
| <div style="margin-bottom:12px;"> |
| <div style="font-size:13px;color:#666;">Foreign flow in your language (correct literal order)</div> |
| <div style="padding:8px 12px;border-radius:12px;background:#f5f5f5;font-size:14px;"> |
| ${nativeLiteral} |
| </div> |
| </div> |
| |
| <div style="margin-bottom:12px;"> |
| <div style="font-size:13px;color:#666;">Your order</div> |
| <div style="padding:8px 12px;border-radius:12px;background:#fafafa;font-size:14px;"> |
| ${userOrder || "<span style='color:#999;'>No attempt recorded.</span>"} |
| </div> |
| </div> |
| |
| <div style="margin-bottom:8px;font-size:13px;font-weight:600;"> |
| Position-by-position comparison |
| </div> |
| <table style="border-collapse:collapse;width:100%;font-size:13px;"> |
| <thead> |
| <tr> |
| <th style="text-align:left;padding:4px 8px;">#</th> |
| <th style="text-align:left;padding:4px 8px;">Correct word</th> |
| <th style="text-align:left;padding:4px 8px;">Your word</th> |
| <th style="text-align:left;padding:4px 8px;">Match</th> |
| </tr> |
| </thead> |
| <tbody> |
| ${rows.join("")} |
| </tbody> |
| </table> |
| |
| <div style="margin-top:8px;font-size:13px;"> |
| ${summaryText} |
| </div> |
| |
| <div style="margin-top:4px;font-size:12px;color:#777;"> |
| Tip: keep the foreign sentence and the translation in mind, and try to place time, place, |
| and verb in the same order as they appear in the foreign sentence. |
| </div> |
| </div> |
| `; |
| } |
| |
| render(); |
| |
| // Re-render when ChatGPT updates tool globals |
| window.addEventListener("openai:set_globals", (event) => { |
| if (event?.detail?.globals?.toolOutput) { |
| render(); |
| } |
| }, { passive: true }); |
| </script> |
| """ |
| return html |
|
|
|
|
| |
|
|
| if __name__ == "__main__": |
| demo.launch(mcp_server=True) |
|
|