GEETAREDDYK commited on
Commit
db20f94
·
0 Parent(s):

Initial commit: Spelling Bee Tutor (NeMo Guardrails demo)

Browse files
Files changed (6) hide show
  1. .gitignore +9 -0
  2. app.py +243 -0
  3. rails/colang/v2/flows.co +79 -0
  4. rails/rails.yaml +8 -0
  5. requirements.txt +1 -0
  6. words.csv +7 -0
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ # macOS
6
+ .DS_Store
7
+
8
+ # screenshots
9
+ screenshots/*.orig
app.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ from types import SimpleNamespace
3
+ from nemoguardrails import LLMRails, RailsConfig
4
+
5
+ # -----------------------------
6
+ # Load CSV (ground-truth data)
7
+ # -----------------------------
8
+ words = []
9
+ with open("words.csv", newline="", encoding="utf-8") as f:
10
+ for row in csv.DictReader(f):
11
+ try:
12
+ row["difficulty"] = int(row.get("difficulty", 5))
13
+ except Exception:
14
+ row["difficulty"] = 5
15
+ words.append(row)
16
+
17
+ # sort hardest -> easiest
18
+ words.sort(key=lambda r: r["difficulty"], reverse=True)
19
+
20
+ ROUND_SIZE = 5
21
+ state = {"index": 0, "in_round": 0, "current": None, "correct": 0}
22
+
23
+
24
+ def _get_row(word: str):
25
+ if not word:
26
+ return None
27
+ wl = word.strip().lower()
28
+ for r in words:
29
+ if r["word"].strip().lower() == wl:
30
+ return r
31
+ return None
32
+
33
+
34
+ # -----------------------------
35
+ # Actions (called from Colang v1)
36
+ # -----------------------------
37
+ def get_next_word():
38
+ """Return next word for the round, or empty string if round finished."""
39
+ if state["in_round"] >= ROUND_SIZE or state["index"] >= len(words):
40
+ return ""
41
+ w = words[state["index"]]["word"]
42
+ state["current"] = w
43
+ state["index"] += 1
44
+ state["in_round"] += 1
45
+ return w
46
+
47
+
48
+ def get_current():
49
+ """Return the current word (or empty string)."""
50
+ return state.get("current") or ""
51
+
52
+
53
+ def check_spelling(word, attempt):
54
+ """Exact (case-insensitive) match check. Returns 'true' or 'false'."""
55
+ ok = (attempt or "").strip().lower() == (word or "").strip().lower()
56
+ if ok:
57
+ state["correct"] += 1
58
+ return "true" if ok else "false"
59
+
60
+
61
+ def get_definition(word):
62
+ r = _get_row(word)
63
+ return r["definition"] if r and r.get("definition") else "No definition found."
64
+
65
+
66
+ def get_origin(word):
67
+ r = _get_row(word)
68
+ return r["origin"] if r and r.get("origin") else "No origin found."
69
+
70
+
71
+ def get_sentence(word):
72
+ r = _get_row(word)
73
+ return r["sentence"] if r and r.get("sentence") else "No example available."
74
+
75
+
76
+ def get_progress():
77
+ return f"Score this round: {state['correct']}/{state['in_round']}."
78
+
79
+
80
+ # -----------------------------
81
+ # Fully compatible Dummy LLM for NeMo Guardrails 0.15
82
+ # -----------------------------
83
+ class DummyLLM:
84
+ """Return a LangChain-like LLMResult with generations + llm_output."""
85
+ def __init__(self, text: str = ""):
86
+ self._text = text
87
+
88
+ def _result(self):
89
+ gen = SimpleNamespace(text=self._text)
90
+ # Guardrails expects these fields to exist
91
+ return SimpleNamespace(generations=[[gen]], llm_output={})
92
+
93
+ # async APIs Guardrails may use
94
+ async def agenerate_prompt(self, *args, **kwargs):
95
+ return self._result()
96
+
97
+ async def agenerate(self, *args, **kwargs):
98
+ return self._result()
99
+
100
+ # sync fallbacks
101
+ def generate_prompt(self, *args, **kwargs):
102
+ return self._result()
103
+
104
+ def generate(self, *args, **kwargs):
105
+ return self._result()
106
+
107
+
108
+ # -----------------------------
109
+ # Wire up NeMo Guardrails (v1)
110
+ # -----------------------------
111
+ config = RailsConfig.from_path("rails")
112
+ rails = LLMRails(config, llm=DummyLLM()) # keep everything offline
113
+
114
+ for fn in [
115
+ get_next_word,
116
+ get_current,
117
+ check_spelling,
118
+ get_definition,
119
+ get_origin,
120
+ get_sentence,
121
+ get_progress,
122
+ ]:
123
+ rails.register_action(fn)
124
+
125
+
126
+ # -----------------------------
127
+ # Response helpers
128
+ # -----------------------------
129
+ def extract_assistant_text(resp) -> str:
130
+ """Try to pull assistant/bot text from various Guardrails return shapes."""
131
+ if isinstance(resp, dict):
132
+ # Common path
133
+ if resp.get("content"):
134
+ return resp["content"]
135
+
136
+ # Messages list
137
+ msgs = resp.get("messages") or resp.get("output", {}).get("messages", [])
138
+ if isinstance(msgs, list) and msgs:
139
+ parts = []
140
+ for m in msgs:
141
+ if isinstance(m, dict) and m.get("role") in ("assistant", "bot"):
142
+ txt = m.get("content") or m.get("text") or ""
143
+ if txt:
144
+ parts.append(txt)
145
+ if parts:
146
+ return "\n".join(parts)
147
+
148
+ # Some builds emit a single message-like dict
149
+ if resp.get("role") in ("assistant", "bot") and resp.get("content"):
150
+ return resp["content"]
151
+
152
+ # Fallback
153
+ return ""
154
+
155
+
156
+ def run_local_engine(user: str) -> str:
157
+ """Deterministic, no-LLM fallback that mirrors our flows."""
158
+ u = (user or "").strip().lower()
159
+
160
+ if u in ("start", "start quiz", "begin", "quiz me"):
161
+ w = get_next_word()
162
+ if not w:
163
+ p = get_progress()
164
+ return f"Round complete. {p}"
165
+ return f"Okay! We'll do 5 words this round, hardest to easiest. Ready?\nSpell this word: {w}"
166
+
167
+ if u == "definition":
168
+ c = get_current()
169
+ d = get_definition(c)
170
+ return f"Definition: {d}"
171
+
172
+ if u == "origin":
173
+ c = get_current()
174
+ o = get_origin(c)
175
+ return f"Origin: {o}"
176
+
177
+ if u == "sentence":
178
+ c = get_current()
179
+ s = get_sentence(c)
180
+ return f"Example: {s}"
181
+
182
+ if u == "next":
183
+ n = get_next_word()
184
+ if not n:
185
+ p = get_progress()
186
+ return f"Round complete. {p}"
187
+ return f"Next word: {n}"
188
+
189
+ if u in ("stop", "end", "finish"):
190
+ p = get_progress()
191
+ return f"Stopping the quiz. {p}"
192
+
193
+ # Otherwise treat as spelling attempt
194
+ c = get_current()
195
+ if not c:
196
+ # If user types a word before starting
197
+ w = get_next_word()
198
+ if not w:
199
+ p = get_progress()
200
+ return f"Round complete. {p}"
201
+ # Re-run attempt logic on new word
202
+ c = w
203
+
204
+ ok = check_spelling(c, user)
205
+ if ok == "true":
206
+ n = get_next_word()
207
+ if not n:
208
+ p = get_progress()
209
+ return f"✅ Correct!\nRound complete. {p}"
210
+ return f"✅ Correct!\nNext word: {n}"
211
+ else:
212
+ return "❌ Not quite. Try again or ask for definition/origin/sentence."
213
+
214
+
215
+ # -----------------------------
216
+ # Tiny CLI loop
217
+ # -----------------------------
218
+ print(
219
+ "Type: 'start' (or 'start quiz'), then 'definition'/'origin'/'sentence', "
220
+ "type your spelling attempt, 'next', or 'stop'."
221
+ )
222
+
223
+ while True:
224
+ user_in = input("You: ").strip()
225
+ if not user_in:
226
+ continue
227
+
228
+ # Try Guardrails first
229
+ try:
230
+ resp = rails.generate(messages=[{"role": "user", "content": user_in}])
231
+ tutor_text = extract_assistant_text(resp)
232
+ except Exception:
233
+ tutor_text = ""
234
+
235
+ # Fallback to our local engine if Guardrails produced nothing visible
236
+ if not tutor_text:
237
+ tutor_text = run_local_engine(user_in)
238
+
239
+ print("Tutor:", tutor_text or "[no response]")
240
+
241
+ if ("Round complete" in tutor_text) or ("Stopping the quiz" in tutor_text):
242
+ break
243
+
rails/colang/v2/flows.co ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ===== Minimal Colang v1 (no intents, no loop, no parentheses) =====
2
+
3
+ # Bot actions available from Python:
4
+ # - get_next_word
5
+ # - get_current
6
+ # - check_spelling
7
+ # - get_definition
8
+ # - get_origin
9
+ # - get_sentence
10
+ # - get_progress
11
+
12
+ # Start quiz (supports "start" and "start quiz")
13
+ define flow start_flow_a
14
+ user says "start"
15
+ bot says "Okay! We'll do 5 words this round, hardest to easiest. Ready?"
16
+ do get_next_word as $w
17
+ bot says "Spell this word: {{$w}}"
18
+
19
+ define flow start_flow_b
20
+ user says "start quiz"
21
+ bot says "Okay! We'll do 5 words this round, hardest to easiest. Ready?"
22
+ do get_next_word as $w
23
+ bot says "Spell this word: {{$w}}"
24
+
25
+ # Definition / origin / sentence for current word
26
+ define flow definition_flow
27
+ user says "definition"
28
+ do get_current as $c
29
+ do get_definition $c as $d
30
+ bot says "Definition: {{$d}}"
31
+
32
+ define flow origin_flow
33
+ user says "origin"
34
+ do get_current as $c
35
+ do get_origin $c as $o
36
+ bot says "Origin: {{$o}}"
37
+
38
+ define flow sentence_flow
39
+ user says "sentence"
40
+ do get_current as $c
41
+ do get_sentence $c as $s
42
+ bot says "Example: {{$s}}"
43
+
44
+ # Spelling attempt: capture ANY free text that isn't one of our commands
45
+ define flow answer_flow
46
+ user says $attempt
47
+ do get_current as $c
48
+ do check_spelling $c $attempt as $ok
49
+ if $ok == "true"
50
+ bot says "✅ Correct!"
51
+ do get_next_word as $n
52
+ if $n == ""
53
+ do get_progress as $p
54
+ bot says "Round complete. {{$p}}"
55
+ else
56
+ bot says "Next word: {{$n}}"
57
+ else
58
+ bot says "❌ Not quite. Try again or ask for definition/origin/sentence."
59
+
60
+ # Skip to next word
61
+ define flow next_flow
62
+ user says "next"
63
+ do get_next_word as $n2
64
+ if $n2 == ""
65
+ do get_progress as $p2
66
+ bot says "Round complete. {{$p2}}"
67
+ else
68
+ bot says "Next word: {{$n2}}"
69
+
70
+ # End quiz
71
+ define flow end_flow_a
72
+ user says "stop"
73
+ do get_progress as $p3
74
+ bot says "Stopping the quiz. {{$p3}}"
75
+
76
+ define flow end_flow_b
77
+ user says "end"
78
+ do get_progress as $p4
79
+ bot says "Stopping the quiz. {{$p4}}"
rails/rails.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ rails:
2
+ config:
3
+ allow_smalltalk: false
4
+ input_character_limit: 256
5
+
6
+ constraints:
7
+ - "Stay on the spelling-bee quiz task only."
8
+ - "If the user asks for unrelated content, gently steer back to the quiz."
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ nemoguardrails==0.9.0
words.csv ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ word,difficulty,definition,origin,sentence
2
+ acrimonious,9,angry and bitter,Latin "acer" (sharp),The debate turned acrimonious quickly.
3
+ bibliophile,8,a person who loves books,Greek "biblion"+"philos",As a bibliophile, she spends hours in libraries.
4
+ capricious,7,impulsive and unpredictable,Italian "capriccio",The stock’s capricious rise surprised investors.
5
+ dilettante,6,an amateur who dabbles,Italian "dilettare",He’s a dilettante in classical music.
6
+ ephemeral,5,lasting a very short time,Greek "ephemeros",Beauty is ephemeral.
7
+ facsimile,4,an exact copy,Latin "facere"+"similis",They sent a facsimile of the contract.