The Tech Pulse

February 9, 202625 min read
Tags
  • Pycharm
  • Ai Agent
  • Ai Agent Python
  • Langgraph
  • Openai
  • Ollama
  • Mcp
  • React Agent
  • Local Llm
  • Python Ai Tutorial
  • Ai Course
Share

AI Agent Course - Build a Language‑Learning Agent with OpenAI, LangGraph, Ollama & MCP

One Sentence Summary

A hands-on guide to building a Langraph-based AI agent that learns languages, cleans data, translates, and creates Anki flashcards automatically.

Main Points

  • Langraph React agent overview: multi-step reasoning with tools and memory for language tasks.
  • Data cleaning workflow: lemmatization, Zip frequency, and JSON-ready final word lists.
  • Open-source vs proprietary models: compare GPT-4/Claude-style models with Olama-backed local LLMs.
  • Custom tools and prompts: design tools with dock strings and system prompts for reliable tool use.
  • Translation toolchain: integrate a translation model to convert word lists into target languages.
  • MCP integration: connect to external servers (e.g., Clanky) to generate Anki flashcards.
  • Debugging and observability: PyCharm AI Agents Debugger visualizes agent traces and graph structure.
  • Translation-enabled workflows: chain tasks (random words, translate, then flashcards) via MCP.
  • Security considerations: prompt injections, data leakage, and multi-agent sandboxing strategies.
  • Practical workflow: end-to-end from data preparation to flashcards and deployment readiness.

Takeaways

  • Start with clean, language-aware word lists before building agent workflows to avoid quality issues.
  • Use lemmatization and frequency-based filtering to reduce vocabulary to core, teachable terms.
  • Test models locally (Olama) to save costs and improve privacy, while benchmarking against paid models.
  • When adding tools, document inputs/outputs precisely and embed tool descriptions in the system prompt.
  • Plan for security: consider multi-agent architectures and sandboxing to mitigate prompt-injection risks.

Summary

This tutorial walks through building a language-learning AI agent from scratch that can generate vocabulary lists, optionally filter by difficulty, translate them with a dedicated translation model, and finally create Anki flashcards via an MCP server. The core stack is Python + LangGraph (ReAct agent) with both proprietary LLMs (OpenAI GPT-4o) and local open-source models via Ollama (reasoning: Qwen 3 8B, translation: Llama 3.2 3B). Data prep is done with spaCy lemmatization + wordfreq Zipf frequency binning and exported as JSON for agent consumption.


Detailed Step-by-Step Breakdown

1) Environment + IDE setup (PyCharm + UV venv)

  1. Install PyCharm (trial ok) via:
    • direct download or JetBrains Toolbox
  2. Create a new project in PyCharm:
    • Project name: language learning agent
    • Interpreter: Project venv
    • Choose detected Python install; tutorial chooses UV (fast installs)

2) Get wordlist dataset into the project

  1. Clone the GitHub dataset repo (crowdsourced “all words in all languages” project):
    • In PyCharm Terminal:
      • cd .. (go up one level)
      • git clone <REPO_URL>
  2. Create a data folder inside your agent project:
    • cd language_learning_agent (back into project)
    • mkdir raw_word_lists
  3. Copy language folders from the cloned repo into your project:
    • Uses cp with curly-brace expansion to copy many dirs at once:
      • cp -r ../all-words-in-all-languages/{...} raw_word_lists/
  4. Fix a dataset typo (Ukrainian folder misspelling):
    • mv <misspelled_folder> ukrainian

Output: raw_word_lists/<language>.csv-style files available locally.


3) Install Python dependencies (PyCharm “Python Packages” tool window)

Install (grouped as in the transcript):

Data + notebook

  • pandas
  • ipywidgets

NLP cleanup

  • spacy
  • spacy-transformers
  • wordfreq (Zipf frequency)

Secrets

  • python-dotenv (referred to as “python.m” / “m”, used as .env loader)

Agent + tooling

  • typing-extensions
  • langchain-core
  • langgraph
  • langchain-openai
  • langchain-ollama
  • langchain-mcp-adapters

4) Create notebook to clean data: clean_wordlists.ipynb

Imports used:

  • csv, os, subprocess
  • punctuation from string
  • pandas
  • spacy, spacy-transformers
  • zipf_frequency from wordfreq

4.1 Inspect raw word counts

  1. Count elements in English CSV (initial sanity check)
  2. Generalize into a function:
    • count_csv_elements_in_file(file_path)
  3. Loop all files in raw_word_lists using os.walk
  4. Build a pandas DataFrame with:
    • language
    • total_words
  5. Use PyCharm DataFrame Chart View:
    • group/sort by language
    • spot anomalies (e.g., Polish huge, Croatian tiny)

5) Download spaCy language models (lemmatization)

  1. Select languages that spaCy supports.
  2. Choose “accurate” models over “efficient” in the spaCy model list.
  3. Create a dictionary mapping language → model name (copied from spaCy page).
  4. Download models using subprocess.run executing:
    • python -m spacy download <model_name>
    • done inside a for loop over model names

Optimize model load by disabling unused pipeline components:

  • disable parser
  • disable ner
  • disable textcat

6) Create clean data directory structure

  1. Create top-level output directory:
  • data/
  1. Create subfolders per language:
  • data/English/, data/Spanish/, etc.
  1. Create only if missing; otherwise handle FileExistsError

7) Implement cleaning pipeline

7.1 Load + strip punctuation

Create function (generic by language):

  • load_and_clean_wordlist(language) → DataFrame
    • load CSV into list
    • strip(punctuation) to remove trailing punctuation
    • return DataFrame with column word

7.2 Lemmatize with spaCy

Function:

  • add_lemma(df, nlp_model, batch_size=1000) → DataFrame
    • process df["word"].tolist() with nlp.pipe(..., batch_size=...)
    • collect doc.lemma_
    • create column lemma

7.3 Add Zipf word frequencies (wordfreq)

Function:

  • add_word_frequencies(df, language) → DataFrame
    • derive 2-letter language code from spaCy model name (split("_")[0])
    • zipf_frequency(lemma, lang_code)
    • store as zipf_frequency column
    • note: many words may get 0 (unknown/ultra-rare)

7.4 Final cleanup + binning + JSON export

Function described (single “do it all”):

  • Group by lemma
    • keep max zipf_frequency
  • Drop entries where zipf_frequency == 0
  • Create difficulty bins:
    • <= 2advanced
    • 2–4intermediate
    • >= 4beginner (as stated in transcript; implement carefully to avoid overlap bugs)
  • Drop unused columns:
    • drop raw word
    • drop numeric zipf_frequency
  • Rename lemmaword
  • Export as JSON (agent-friendly):
    • filename: wordlist_clean.json

Convenience wrapper:

  • create_clean_wordlist(language)
    • load spaCy model
    • load/strip punctuation
    • lemmatize
    • add Zipf freq
    • dedupe + filter + bin
    • write JSON

Run for Spanish as validation, then for all target languages.


8) Build LangGraph ReAct agent (core agent)

8.1 Create main.py

Core components:

  1. AgentState class (working memory)
    • Initially includes messages
  2. Tools list:
    • start with custom tool get_n_random_words
  3. Choose LLM:
    • initially ChatOpenAI(model="gpt-4o")
  4. Assistant function:
    • defines system prompt
    • binds tools to the model
    • returns dict updating state

8.2 OpenAI API setup (if using GPT-4o)

  1. Go to platform.openai.com
  2. Create API key: Settings → API keys → “Create new secret key”
  3. Add billing credit via Billing UI
  4. Load key securely using python-dotenv
    • from dotenv import load_dotenv
    • load_dotenv()
    • store key in .env file in project root:
      • OPENAI_API_KEY=...

9) Implement custom LangGraph tools (tools.py)

9.1 Tool 1: get random words

Tool:

  • @tool get_n_random_words(language: str, n: int) -> list

Logic:

  • build path to: data/<language>/wordlist_clean.json
  • json.load(...)
  • random sample n items
  • return list of word only

Critical: include

  • strict type hints
  • mandatory docstring
  • tool decorator: from langchain_core.tools import tool

Docstring generated via JetBrains AI Assistant (“Write documentation”) then manually verified.

9.2 Tool description injected into prompt

Copy/paste tool signature + docstring into a string:

  • textual_description_of_tools Embed into system prompt:
  • “You have access to the following tools: {textual_description_of_tools}”

Also add few-shot examples mapping user queries → extracted fields:

  • source language
  • number of words

Update AgentState fields:

  • source_language: Optional[str]
  • number_of_words: Optional[int]

Return dict from assistant includes those keys.


10) Build the LangGraph graph

Imports:

  • from langgraph.graph import StateGraph, START
  • from langgraph.prebuilt import ToolNode, tools_condition

Structure:

  • nodes: assistant, tools
  • edges:
    • START -> assistant
    • assistant -> tools conditional via tools_condition
    • tools -> assistant (loop)

Compile graph and invoke async:

  • Create user input as HumanMessage
  • call graph.ainvoke({...})

11) Debug with PyCharm AI Agents Debugger

  1. Install plugin:
    • Shift+Shift → “Plugins” → Marketplace → AI Agents Debugger
  2. Run main.py
  3. Observe:
    • tool calls (arguments + outputs)
    • graph view (assistant/tools loop)

12) Switch to local open-source reasoning model via Ollama (Qwen 3)

12.1 Install Ollama

  • Install from official site: ollama.com/download

12.2 Pull a reasoning model

Terminal:

  • list installed models:
    • ollama list
  • pull Qwen 3 8B:
    • ollama pull qwen3:8b (the transcript uses “run … then change to pull”; exact tag may vary by Ollama listing)

12.3 Swap model in code

Replace:

  • ChatOpenAIChatOllama (from langchain-ollama)
  • LLM init becomes:
    • ChatOllama(model="qwen3:8b")

Run agent again; expect similar tool selection but with more verbose reasoning traces.


13) Add Tool 2: get random words by difficulty

Tool:

  • @tool get_n_random_words_by_difficulty_level(language: str, n: int, difficulty_level: str) -> list

Logic:

  • load JSON
  • filter where word_difficulty == difficulty_level
  • sample n from filtered pool
  • return words only

Docstring: must explicitly list valid difficulty values:

  • beginner, intermediate, advanced

Update agent wiring:

  • import tool
  • add to tools list
  • add word_difficulty: Optional[str] to AgentState
  • include in assistant return dict + graph invocation dict
  • update system prompt instructions & examples to map:
    • “hard” → advanced
    • “average” → intermediate
    • “basic” → beginner

Test prompts:

  • “10 average difficulty words in English”
  • “30 basic words in Spanish”
  • “5 random words in German” (ensure old tool still works)

14) Add Tool 3: translate words using a translation LLM (Llama 3.2 3B)

14.1 Pull translation model

Terminal:

  • ollama pull llama3.2:3b (tag may vary)

14.2 Initialize translation model in tools.py

  • translation_llm = ChatOllama(model="llama3.2:3b")

14.3 Tool: translate_words

Tool signature:

  • @tool translate_words(random_words: list, source_language: str, target_language: str) -> dict

Prompt strategy:

  • instruct: “You are a precise translation engine…”
  • force JSON-only output:
    • {"translations":[{"source":"...","target":"..."}]}

Parsing strategy (important implementation detail):

  1. Try json.loads(raw_response)
  2. On failure:
    • regex extract between first { and last }
    • parse that substring as JSON
  3. Convert list-of-dicts into a simplified dictionary:
    • {source_word: target_word}
  4. Validate all original words exist as keys
  5. Return sanitized structure:
    • {"translations": [{"source": "...", "target": "..."} ...]}

Update agent wiring:

  • import tool
  • add to tools list
  • add target_language: Optional[str] to AgentState
  • update prompt instructions and examples (include translation requests)

Test prompt:

  • “10 advanced words in German and translate them to English”
  • “30 basic words in English and translate them to Spanish”

Observed pitfall (agent behavior):

  • model may mistakenly pass a placeholder list into translate_words instead of tool output, then self-correct.
  • highlights nondeterminism + need for checks/guardrails.

15) Integrate MCP to create Anki flashcards (Clanky + AnkiConnect)

15.1 Install external pieces

  1. Anki desktop app
    • download from apps.ankiweb.net
  2. Install AnkiConnect add-on inside Anki
  3. Install Clanky MCP server
    • clone repo
    • ensure npm installed
    • build:
      • npm run build
    • locate built server entry:
      • build/index.js

15.2 Add MCP client in main.py

Import:

  • MultiServerMCPClient from langchain-mcp-adapters

Add variable:

  • clanky_js = "<path_to>/clanky/build/index.js"

Update setup_tools() (async):

  1. instantiate MCP client with a server definition using stdio transport:
    • server name: e.g. "clanky"
    • command: node
    • args: [clanky_js]
  2. call:
    • mcp_tools = await client.get_tools()
  3. return:
    • local_tools + mcp_tools

Now the agent can see MCP tool schemas automatically (names, descriptions, argument JSON schemas).

15.3 Update system prompt with explicit workflow examples

Because ordering matters, examples explicitly instruct tool sequence:

Example pattern:

  1. get words (random or difficulty)
  2. translate_words (if requested)
  3. MCP tool: create_deck
  4. MCP tool: create_card per word (front/back)

15.4 Run end-to-end flashcard creation

Precondition:

  • Anki app running in background

Prompt example:

  • “Please get 10 basic words in German, translate them to English, and add them to an Anki deck called German easy.”

Expected tool chain:

  1. get_n_random_words_by_difficulty_level(language="German", n=10, difficulty_level="beginner")
  2. translate_words([...], source_language="German", target_language="English")
  3. MCP: create_deck(name="German easy")
  4. MCP: create_card(deck="German easy", front=..., back=...) repeated

Verify inside Anki:

  • deck exists
  • card count matches
  • cards show word ↔ translation

Key Technical Details

Core technologies

  • PyCharm (IDE), JetBrains Toolbox, JetBrains AI Assistant, AI Agents Debugger
  • LangGraph (agent graph), LangChain Core (messages/tools)
  • ReAct agent pattern via assistant/tool loop
  • MCP (Model Context Protocol) via langchain-mcp-adapters
  • Ollama for local LLM hosting
  • Models:
    • OpenAI GPT-4o (paid, fast, strong reasoning, multimodal)
    • Qwen 3 8B (local reasoning)
    • Llama 3.2 3B (local translation)
  • NLP cleaning:
    • spaCy lemmatization with language models
    • wordfreq.zipf_frequency for frequency + difficulty binning
  • Output format:
    • cleaned vocab stored as JSON for agent consumption
  • Flashcards:
    • Anki + AnkiConnect
    • Clanky MCP server

Agent state fields added over time

  • messages
  • source_language
  • number_of_words
  • word_difficulty
  • target_language

Tooling contracts

  • LangGraph tools require:
    • strict type hints
    • strong docstrings (agent uses these to decide tool usage)
    • stable argument names/types

Pro Tips

  • Use UV (or similarly fast installers) to speed dependency iteration.
  • In spaCy, disable unused pipeline components (parser, ner, textcat) to speed up lemmatization.
  • When forcing structured output from an LLM, always:
    • demand strict JSON
    • implement a fallback parser (regex brace extraction)
    • validate completeness (all inputs translated)
  • Put “valid values” directly in tool docstrings (e.g., beginner/intermediate/advanced) so the agent can map synonyms like “basic/average/hard”.
  • Use PyCharm’s AI Playground to compare model behavior (verbosity, correctness) before swapping into the agent runtime.
  • Prefer a specialized model for translation (Llama 3.2 3B) instead of a reasoning model to reduce hallucinations and cost.
  • For MCP workflows, provide few-shot examples with explicit tool order; ReAct agents can otherwise mis-order steps.
  • Keep the agent’s “working memory” explicit by adding state fields whenever the tool interface expands.

Potential Limitations/Warnings

  • Crowdsourced dataset quality varies wildly by language (missing data, over-generated inflections, typos). Word count sanity checks are necessary but not sufficient.
  • Lemmatization out of context can be wrong (spaCy errors on rare/proper nouns); expect occasional bad lemmas.
  • Filtering on zipf_frequency == 0 can be too aggressive for some languages (e.g., Polish ended up far below expected vocab size).
  • ReAct nondeterminism: the agent may call tools in the wrong way (e.g., using placeholder inputs) and then self-correct. Add validations/guardrails.
  • MCP introduces real security risk:
    • Agents can be vulnerable to prompt injection
    • lethal trifecta” risk when an agent has sensitive data access + untrusted inputs + outbound capability
  • Never commit .env or API keys; rotate keys immediately if leaked.

Recommended Follow-Up Resources

  • Hugging Face Agents Course (ReAct + broader agent patterns; referenced in transcript)
  • LangGraph tutorials on structured workflows + productionization testing
  • spaCy language models + lemmatization docs (for model selection + pipeline tuning)
  • wordfreq documentation on Zipf frequency
  • Ollama docs (model tags, quantization, performance tuning)
  • MCP ecosystem directories (e.g., mcpservers.org) for discovering servers
  • Clanky MCP server docs + build instructions
  • AnkiConnect documentation (API + add-on setup)
  • Security reading mentioned:
    • article by Cory Katzmaier on prompt injection / agent security (as referenced)
    • concepts: prompt injection, sandboxing, privilege separation, multi-agent isolation

Below is a reproducible project blueprint (folder layout + files + commands) that matches what’s described in the transcript. I’m keeping it execution-first, with exact file paths, tool signatures, and CLI commands where the transcript provided them. Where the transcript didn’t give exact literals (e.g., the exact repo URL or exact Ollama model tag spelling), I put it under Assumption: Standard/Typical Setup.


Summary

You’ll end with:

  • Cleaned vocab JSONs: data/<Language>/wordlist_clean.json
  • Agent runtime: main.py
  • Tools module: tools.py
  • Optional notebook: clean_wordlists.ipynb
  • Optional MCP integration for Anki flashcards via Clanky + AnkiConnect

Detailed Step-by-Step Breakdown

0) Project folder layout (final state)

Create/maintain this structure:

language_learning_agent/
  main.py
  tools.py
  .env                      # secrets (DO NOT COMMIT)
  raw_word_lists/           # copied from the GitHub dataset repo
    English.csv             # naming depends on repo; may be language-named files
    Spanish.csv
    German.csv
    ...
  data/                     # cleaned JSON outputs
    English/
      wordlist_clean.json
    Spanish/
      wordlist_clean.json
    German/
      wordlist_clean.json
  notebooks/
    clean_wordlists.ipynb

1) Create the PyCharm project + virtualenv

  1. In PyCharmNew Project
  2. Name: language learning agent
  3. Interpreter: Project venv
  4. Choose UV interpreter if available (as in transcript)

Assumption: Standard/Typical Setup

  • If you don’t have UV, use regular venv. Nothing else changes.

2) Clone dataset repo + copy into raw_word_lists/

In PyCharm Terminal:

cd .. git clone <DATASET_REPO_URL> cd language_learning_agent mkdir -p raw_word_lists

Copy the dataset contents into your project’s raw_word_lists/.

The transcript describes using cp -r with curly-brace expansion to copy multiple language directories at once. Since the exact dataset layout/paths weren’t fully specified, here are two common patterns:

Pattern A: dataset has language folders

cp -r ../all-words-in-all-languages/* raw_word_lists/

Pattern B: dataset has files you want

cp -r ../all-words-in-all-languages/wordlists/* raw_word_lists/

Fix the Ukrainian folder typo (only if applicable in your repo):

mv raw_word_lists/<misspelled_ukrainian_folder> raw_word_lists/ukrainian

Assumption: Standard/Typical Setup

  • Replace <DATASET_REPO_URL> with the repo from the transcript (“all words in all languages” crowdsourced project).
  • Replace paths depending on how that repo is structured on disk.

3) Install dependencies

Install these in your venv (PyCharm “Python Packages” window or pip/uv). CLI example:

# if using uv uv pip install pandas ipywidgets spacy spacy-transformers wordfreq python-dotenv \ typing-extensions langchain-core langgraph langchain-openai langchain-ollama langchain-mcp-adapters

4) Create notebook for data cleaning: notebooks/clean_wordlists.ipynb

In PyCharm: Right-click project → NewJupyter Notebook → name it clean_wordlists.ipynb inside notebooks/.

The notebook implements:

  • counting raw word counts
  • strip(punctuation)
  • spaCy lemmatization
  • wordfreq.zipf_frequency
  • binning difficulty
  • exporting JSON to data/<Language>/wordlist_clean.json

Assumption: Standard/Typical Setup

  • The transcript’s code is described, not fully shown line-for-line. Implement using the functions listed below.

4.1 Cleaning function definitions (implement in notebook)

Use these function names (as described):

  • count_csv_elements_in_file(file_path)
  • load_and_clean_wordlist(language)
  • add_lemma(df, nlp_model, batch_size=1000)
  • add_word_frequencies(df, language)
  • cleanup_and_export(df, language)
  • create_clean_wordlist(language)

Key rules from transcript:

  • strip punctuation: word.strip(punctuation)
  • load spaCy model with disabled components: disable parser, ner, textcat
  • zipf freq language code derived from spaCy model name: model_name.split("_")[0]
  • drop words where zipf frequency == 0
  • groupby lemma to dedupe
  • difficulty bins:
    • <= 2advanced
    • 2–4intermediate
    • >= 4beginner (be careful implementing boundaries)

Export JSON named: wordlist_clean.json


5) Implement tools module: tools.py

Create tools.py at project root.

Tool 1: get_n_random_words

  • Reads data/<language>/wordlist_clean.json
  • Random-samples n
  • Returns list[str]
  • Must have @tool, type hints, and docstring

Tool 2: get_n_random_words_by_difficulty_level

  • Same as above, but filter by word_difficulty
  • difficulty_level must be one of:
    • beginner, intermediate, advanced
  • Returns list[str]

Tool 3: translate_words

  • Uses translation LLM (ChatOllama with Llama 3.2 3B)
  • Input: random_words: list, source_language: str, target_language: str
  • Output: dict shaped like:
    • {"translations": [{"source": "...", "target": "..."}, ...]}

Parsing strategy from transcript:

  • try json.loads(response)
  • fallback: regex extract content between first { and last }
  • validate all input words translated

Here is a complete tools.py that matches the described behavior:

import json import os import random import re from typing import Dict, List from langchain_core.tools import tool from langchain_ollama import ChatOllama # Translation model (local) # Assumption: Standard/Typical Setup: # - Ollama model tag is "llama3.2:3b" (tag spelling may vary; use `ollama list` to confirm). TRANSLATION_LLM = ChatOllama(model="llama3.2:3b") def _wordlist_path(language: str) -> str: """Build the path to the cleaned wordlist JSON for a given language.""" return os.path.join("data", language, "wordlist_clean.json") @tool def get_n_random_words(language: str, n: int) -> List[str]: """ Return a list of n random words in the specified language. Args: language: Language folder name under ./data (e.g., "English", "Spanish", "German"). n: Number of random words to return. Returns: A list of n random words (strings) from the cleaned wordlist JSON. """ path = _wordlist_path(language) with open(path, "r", encoding="utf-8") as f: data = json.load(f) # The JSON is expected to contain entries with at least "word" and "word_difficulty". # We will sample from all entries. all_words = [row["word"] for row in data.values()] if isinstance(data, dict) else [row["word"] for row in data] return random.sample(all_words, k=min(n, len(all_words))) @tool def get_n_random_words_by_difficulty_level(language: str, n: int, difficulty_level: str) -> List[str]: """ Return a list of n random words in the specified language filtered by difficulty level. Valid difficulty_level values are: "beginner", "intermediate", "advanced". Args: language: Language folder name under ./data (e.g., "English", "Spanish", "German"). n: Number of random words to return. difficulty_level: Difficulty filter. Must be one of "beginner", "intermediate", "advanced". Returns: A list of n random words (strings) from the cleaned wordlist JSON filtered by difficulty_level. """ difficulty_level = difficulty_level.strip().lower() if difficulty_level not in {"beginner", "intermediate", "advanced"}: raise ValueError('difficulty_level must be one of: "beginner", "intermediate", "advanced".') path = _wordlist_path(language) with open(path, "r", encoding="utf-8") as f: data = json.load(f) rows = list(data.values()) if isinstance(data, dict) else list(data) filtered = [r["word"] for r in rows if str(r.get("word_difficulty", "")).lower() == difficulty_level] if not filtered: return [] return random.sample(filtered, k=min(n, len(filtered))) @tool def translate_words(random_words: List[str], source_language: str, target_language: str) -> Dict: """ Translate a list of words from a source language to a target language using a translation LLM. The tool attempts to force the LLM to output strict JSON. If the LLM outputs extra text, the tool extracts the first JSON object found and parses it. Args: random_words: List of words to translate. source_language: Language of the input words. target_language: Language to translate the words into. Returns: A dict of the form: {"translations": [{"source": "<word>", "target": "<translation>"}, ...]} """ prompt = ( "You are a precise translation engine.\n" f"You will be given a list of words to translate from {source_language} to {target_language}.\n\n" "Only return valid JSON with exactly this structure:\n" '{"translations": [{"source": "<SOURCE_WORD>", "target": "<TARGET_WORD>"}]}\n' "No explanations, no extra fields, no markdown.\n\n" f"Words: {random_words}\n" ) raw = TRANSLATION_LLM.invoke(prompt).content # First attempt: direct JSON parse try: parsed = json.loads(raw) except Exception: # Fallback: extract content between the first '{' and last '}'. match = re.search(r"\{.*\}", raw, flags=re.DOTALL) if not match: return {"translations": []} parsed = json.loads(match.group(0)) translation_list = parsed.get("translations", []) if isinstance(parsed, dict) else [] if not isinstance(translation_list, list): translation_list = [] # Simplify and validate simplified = {} for item in translation_list: if not isinstance(item, dict): continue src = item.get("source") tgt = item.get("target") if isinstance(src, str) and isinstance(tgt, str): simplified[src] = tgt # Ensure every word was translated final_translations = [] for w in random_words: if w in simplified: final_translations.append({"source": w, "target": simplified[w]}) return {"translations": final_translations}

6) Implement agent runner: main.py

This file creates:

  • AgentState (messages + extracted fields)
  • assistant function + system prompt (includes tool descriptions + examples)
  • LangGraph graph: START → assistant ↔ tools
  • async invocation

Here is a complete main.py blueprint aligned to the transcript’s structure:

import asyncio import os from typing import Optional, TypedDict, List, Dict, Any from dotenv import load_dotenv from langchain_core.messages import HumanMessage, SystemMessage from langchain_ollama import ChatOllama from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, START from langgraph.prebuilt import ToolNode, tools_condition from tools import ( get_n_random_words, get_n_random_words_by_difficulty_level, translate_words, ) # Optional MCP (only if you enable Anki/Clanky integration) # from langchain_mcp_adapters import MultiServerMCPClient load_dotenv() class AgentState(TypedDict, total=False): messages: List[Any] source_language: Optional[str] number_of_words: Optional[int] word_difficulty: Optional[str] target_language: Optional[str] # Choose one: USE_OPENAI = False # set True to use GPT-4o OPENAI_MODEL = "gpt-4o" OLLAMA_REASONING_MODEL = "qwen3:8b" # Assumption: tag name; verify with `ollama list` def _tool_text_descriptions() -> str: # In the transcript, this is copy/pasted tool signature + docstring into prompt. # Here we provide a compact summary string that still conveys name + args + purpose. return "\n\n".join( [ "Tool: get_n_random_words(language: str, n: int) -> List[str]\n" "Returns n random words from data/<language>/wordlist_clean.json.", "Tool: get_n_random_words_by_difficulty_level(language: str, n: int, difficulty_level: str) -> List[str]\n" 'Returns n random words filtered by difficulty_level. Valid difficulty_level: "beginner", "intermediate", "advanced".', "Tool: translate_words(random_words: List[str], source_language: str, target_language: str) -> Dict\n" 'Translates a list of words and returns {"translations":[{"source":..., "target":...}, ...]}.', ] ) def _system_prompt() -> str: tools_desc = _tool_text_descriptions() return f""" You are a helpful language learning assistant. You have access to the following tools: {tools_desc} Your job is to: 1) Identify the source language. 2) Identify the number of words. 3) Identify whether the user wants a specific difficulty level (beginner/intermediate/advanced) or just random words. 4) Identify whether the user wants the words translated to a target language. Examples: Input: "get 20 random words in Spanish" - source_language: Spanish - number_of_words: 20 - word_difficulty: None - target_language: None Tool workflow: get_n_random_words Input: "get 10 hard words in German" - source_language: German - number_of_words: 10 - word_difficulty: advanced - target_language: None Tool workflow: get_n_random_words_by_difficulty_level Input: "get 15 easy words in English and translate them to Spanish" - source_language: English - number_of_words: 15 - word_difficulty: beginner - target_language: Spanish Tool workflow: get_n_random_words_by_difficulty_level -> translate_words Input: "get 50 random words in German and translate them to English" - source_language: German - number_of_words: 50 - word_difficulty: None - target_language: English Tool workflow: get_n_random_words -> translate_words When solving the task, decide which tool(s) to call and in what order. """.strip() def _llm(): if USE_OPENAI: # Requires OPENAI_API_KEY in .env return ChatOpenAI(model=OPENAI_MODEL) return ChatOllama(model=OLLAMA_REASONING_MODEL) def assistant(state: AgentState) -> Dict[str, Any]: # LangGraph will pass state in; we respond with updated state keys llm = _llm() messages = state.get("messages", []) sys = SystemMessage(content=_system_prompt()) model_input = [sys] + messages # Bind tools each time (simple blueprint approach) tools = state.get("_bound_tools", []) # internal stash if you want if not tools: # local tools only (MCP can be appended in setup_tools) tools = [get_n_random_words, get_n_random_words_by_difficulty_level, translate_words] llm = llm.bind_tools(tools) resp = llm.invoke(model_input) # Append assistant response to message history new_messages = messages + [resp] # Return updated state return { "messages": new_messages, "source_language": state.get("source_language"), "number_of_words": state.get("number_of_words"), "word_difficulty": state.get("word_difficulty"), "target_language": state.get("target_language"), "_bound_tools": tools, } async def setup_tools(): # Local tools local_tools = [get_n_random_words, get_n_random_words_by_difficulty_level, translate_words] # MCP tools (optional) # Assumption: Standard/Typical Setup: # - You built Clanky and have build/index.js path. # # clanky_js = "/absolute/path/to/clanky/build/index.js" # client = MultiServerMCPClient( # { # "clanky": { # "command": "node", # "args": [clanky_js], # "transport": "stdio", # } # } # ) # mcp_tools = await client.get_tools() # return local_tools + mcp_tools return local_tools async def build_graph(): tools = await setup_tools() graph = StateGraph(AgentState) graph.add_node("assistant", assistant) graph.add_node("tools", ToolNode(tools)) graph.add_edge(START, "assistant") graph.add_conditional_edges("assistant", tools_condition) graph.add_edge("tools", "assistant") return graph.compile() async def main(): app = await build_graph() user_prompt = "Please get 10 basic words in German and translate them to English." result = await app.ainvoke( { "messages": [HumanMessage(content=user_prompt)], "source_language": None, "number_of_words": None, "word_difficulty": None, "target_language": None, } ) # Print final agent message content (last message) final_msg = result["messages"][-1] print(final_msg.content if hasattr(final_msg, "content") else final_msg) if __name__ == "__main__": asyncio.run(main())

7) Local model setup with Ollama

Install Ollama (per transcript). Then:

ollama list

Pull models:

ollama pull qwen3:8b ollama pull llama3.2:3b

Assumption: Standard/Typical Setup

  • Model tags may differ slightly (e.g., qwen3:8b vs qwen3:8b-instruct). Use ollama list and the Ollama library page variant name.

8) OpenAI model setup (optional)

If USE_OPENAI=True:

Create .env in project root:

OPENAI_API_KEY=your_key_here

Then main.py can use ChatOpenAI(model="gpt-4o").


9) MCP + Anki flashcards (optional integration)

From transcript, prerequisites:

  1. Install Anki desktop app
  2. Install AnkiConnect add-on
  3. Clone Clanky MCP repo and build:
git clone <CLANKY_REPO_URL> cd clanky npm install npm run build

Find:

  • clanky/build/index.js

Then in main.py uncomment the MCP block in setup_tools() and set:

  • clanky_js = "/absolute/path/to/clanky/build/index.js"

Also: launch Anki before running your agent.


Key Technical Details

  • Tool contracts must be strict: type hints + docstrings + @tool
  • Clean wordlist format expected by tools:
    • JSON containing entries with keys:
      • word
      • word_difficulty
  • Difficulty labels are fixed:
    • beginner / intermediate / advanced
  • Translation tool forces JSON output but still implements fallback parsing.

Pro Tips

  • Keep data/<Language>/wordlist_clean.json small and validated early (English/Spanish) before processing all languages.
  • If wordfreq returns many zeros for a language, don’t blindly drop them—consider loosening filtering for that language.
  • For translation robustness: add logging of raw LLM output in translate_words during development.
  • For ReAct unpredictability: add validation that tool inputs come from prior tool outputs, not placeholders.

Potential Limitations/Warnings

  • Dataset quality is inconsistent; Polish was highlighted as especially tricky.
  • spaCy lemmatization out-of-context will be imperfect.
  • Agents are nondeterministic; tool-order mistakes can happen.
  • MCP tool access + external apps increases security exposure; don’t mix secrets + untrusted inputs + outbound actions.

Recommended Follow-Up Resources

  • Hugging Face Agents Course (ReAct + agent patterns)
  • LangGraph docs (structured workflows, testing, production features)
  • spaCy models documentation (language support + pipelines)
  • wordfreq docs (Zipf frequency interpretation)
  • Ollama docs (model tags, quantization, performance)
  • mcpservers.org for MCP server discovery
  • Clanky + AnkiConnect documentation for detailed setup/debugging

If you run into anything that doesn’t match your dataset’s exact file naming (CSV filenames, folder names), the only thing you’ll need to adjust is the raw ingestion path in the notebook and the language folder names under data/. Everything else stays the same.

FAQ

Do I need strong NLP background before starting this project?

No. Basic Python and API usage is enough to start, then you can deepen NLP knowledge as you build.

Why combine OpenAI with Ollama in the same workflow?

It gives you flexibility: hosted reasoning quality when needed, and local model control for cost/privacy-sensitive tasks.

What part fails most often in practice?

Data quality and tool orchestration. Clean your dataset early and log tool inputs/outputs during debugging.

Related reading

Get New Posts

Follow on your preferred channel for new articles, notes, and experiments.

Related Posts