|
|
""" |
|
|
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) |
|
|
|