I’m using Anki to learn Chinese and one of the problems with using sentence cards is they easily invoke pattern matching when the point of the cards is often to practice some grammar structure. For these cards it would make sense to ask AI to “for practicing grammar while learning Chinese, make a similarly structured sentence to this one using simple vocabulary: <the card’s sentence>”. And then for listening cards (where audio is played and you try to understand it), the AI’s output would be TTS’d. Possibly with HyperTTS’s real time TTS or something. But I’m failing to find an addon that would do the asking AI for a similar sentence part. Anybody else using one for this kind of feature?
- You can do that today. Generate your variations of sentences if you wish. Add them to a spreadsheet and them import them into Anki. It does not necessarily have to be a feature within Anki.
- I’d be very suspicious of AI generated translations. You may end up learning complete rubbish that you believe is reasonable Chinese (or any other language). The translation engines still make many mistakes. AI itself of produces grammatically corrupt drivel. Be ware!
I don’t want a dozen different variations of practicing the same grammar structure in my deck. For example, I have a card like:
“Yesterday I went to the supermarket.”
The 2nd time I see the card after learning it, the similarity generator would make something like:
“Yesterday I went to see a movie.”
The 3rd time it might make:
“Today I came here.”
Basically swapping out simple nouns and verbs but keeping the grammar structure the same. The state of the art models are remarkably good at translation if you give it enough context and the model had enough training for the languages (which is true for English/Chinese corpora). It can keep the same structure and make sure the substitutions are (almost) always semantically sensible.
Manually generating these variations is tedious, bloats the deck, and kind of misses the point. The point is to memorize the grammar structure, so the spaced repetition should count all uses of the same grammar structure together.
In fact the note could contain an optional field to customize the prompt for the card so the AI knows if there’s a certain part of the structure to preserve.
hmm OK. I understand what you want, but (a) I think adding the services of an AI engine would bloat the deck, as you say, and (b) if I’m learning a language, I want to know that what I am learning is 100% accurate from native-speaker and grammatical points of view. “almost always” is not good enough, I think.
Another way to do it, might be to have e.g. ten variations of your sentence and then use JavaScript to randomise, which one is displayed. From a pedagogical point of view, this would only work, if the phrases or clauses are exactly the same — apart from the single grammar item that you want to change. Any change in preposition, particle, case, word order, etc., should necessitate a new card/note.
If you really just want to memorize the grammar structure, your note/card should be about that structure – not about vocab and translation.
The problem you have is that if you inject randomization into what sentence is chosen for the front of the card, and then you get the card wrong – there’s nothing that guarantees you’re going to get that sentence the next time you see the card. Maybe you’ll get a sentence that you always get right, and didn’t need to study more often at all. So you’ll be studying some sentences much more often than you need to, and other sentences much less often than you need to, with no rhyme or reason as to which.
Really, spaced repetition doesn’t fit perfectly with “practicing” sentence patterns – because it’s for memorization. I know plenty of people use it for “practice” tasks, but the only way that makes sense is if they are working towards being able to do the task more easily, more quickly, more comfortably, more fluently. For that, you don’t need random sentences – you need the same sentences, just lots of them.
I did consider the hardcoded variations with an addon that randomly selects from them. Is there an addon to do that? Or where would the JavaScript go? I pretty much agree with your idea of pedagogical restrictions, and it could still be very useful for avoiding pattern matching.
Studying a templated grammar structure does not seem helpful for my practice, e.g. to templatize my examples above, “[time phrase] [subject] [verb] [object]”? And how would that help for listening practice? ![]()
In order to avoid the randomization causing problems when getting the card wrong due to forgotten vocabulary, the variations would have to use mature vocabulary where that’s not really an issue. Doing that dynamically with AI would indeed be tricky. Matta’s randomly selected variations idea is looking better…
- Enter the sentences into a card’s field as a list. You can ask AI to output the HTML code in the following format and paste it into a source code editor (the
<>button in the top right corner of the field in the Anki Editor):<ol><li>Sentence1</li><li>Sentence2</li><li>Sentence3</li>....</ol> - Wrap the {{SentenceField}} in your card template in a div:
<div id="randomize">{{SentenceField}}</div> - Paste the following JS code at the bottom of the same template:
<script> items = document.getElementById("randomize").children[0]?.children; randomIndex = Math.floor(Math.random() * items.length); items[randomIndex].classList.add("selected"); </script> - Add the following styles to the Styling tab:
#randomize > * { list-style:none; margin: 0; padding: 0; } #randomize > * > * { display: none; } #randomize > * > .selected { display: inline; }
- If you don’t hold the notes in a spreadsheet, export them so that you can load them into a spreadsheet. I use LibreOffice Calc (free).
- I’d then have ten columns that held ten options, for your variations.
- Put some JavaScript on the card and use a random number determiner to determine which of the ten choices you use. The function
getRandomArbitrary(min, max)found in this post would seem to be your solution for generating a random number between 1 and 10, for example. Place that random number in a variable. - Then display that randomly chosen word and make the same choice by using the same variable for the translation.
It obviously wouldn’t. That’s yet another kind of “practice” that isn’t well suited to a memorization app like Anki.
Very neat! It’s even able to pass the random index to the back template so it shows the corresponding translation/pinyin/sound for the randomly select sentence! What about selecting from a list of [sound:…]items? This display:none approach doesn’t seem to affect playing of sounds.
You can append the following line to the end of the script:
items[randomIndex].querySelector("a.replay-button.soundLink")?.click();
For reliability, it’s better to enable “Don’t play audio automatically” in the deck’s settings. This won’t work, if there is more than one sound that needs to be played, however (see this discussion: Turn off auto-play for hidden elements - #17 by dae). And will require additional workaround if intended to be used with AnkiDroid. I have a card template with a fully implemented randomized audio autoplay function, if you’d like a reference. It supports audio autoplay on AnkiWeb in addition to everything else as well:
Memrise card template [support thread] - #197 by Eltaurus
My method is to have an LLM, such as Gemini, generate the content, and then import it into Anki.
For example, if I’m studying the present continuous tense in English today, I would generate 10 or 20 sentences.
She is reading a book now.
They are playing football in the park.
He is cooking dinner for his family.
We are studying for our exams.
The cat is sleeping on the sofa.
I am writing an email to my boss.
It is raining heavily outside.
You are listening to music.
The children are watching cartoons.
My brother is working on his computer.
Maybe an add-on could do the work.
You would put the grammatical structure in a field, and the add-on would request the AI an appropriate question and its answer.
The add-on would change what’s displayed on the screen by this received data.
However, such a system would thus not work offline, if it’s for example a LLM, and may become annoying because you’d have to wait everytime you review a new card, so that the add-on requests the AI and receives its result.
But I think it’s possible.
I made an add-on for myself that generates new sentences with pronunciation for all the words that have a certain tag “GenerateSentence”. The .config file is used for the API key and for indicating which fields hold the Word and Sentence. Currently, I ask it to generate a sentence with plenty of context but maybe you can use it to generate a sentence that uses the same structure as the previous sentence (you would need to modify it so the LLM knows the current sentence instead of only the word though).
Anyway here is the code:
```
import os
import sys
import warnings
# Get the path to the add-on's directory
addon_path = os.path.dirname(os.path.abspath(__file__))
# Define the vendored directory path
vendored_path = os.path.join(addon_path, "vendored")
# Add the vendored path to the system path, so Python can find the libraries
if vendored_path not in sys.path:
sys.path.insert(0, vendored_path)
# --- A. Anki Imports and AnkiConnect setup ---
from aqt import mw, QAction
from aqt.operations import QueryOp
from aqt.utils import showInfo
from anki import hooks
# --- B. Standard Library Imports ---
import random
import json
import requests
import re
import unicodedata
import base64
import time
import copy
# --- C. Add-on-specific imports and libraries ---
# These libraries are imported from the 'vendored' directory.
import jieba
from pypinyin import pinyin, Style
# Set the jieba logger level to suppress its output
import logging
logging.getLogger('jieba').setLevel(logging.CRITICAL)
# --- F. Main Add-on Logic and API Key setup ---
# Load the API key and field names from a config.json file
config_data = {}
config_path = os.path.join(addon_path, "config.json")
try:
with open(config_path, 'r', encoding='utf-8') as f:
config_data = json.load(f)
except FileNotFoundError:
showInfo("Error: config.json file not found. Please create it with your API key.")
except Exception as e:
showInfo(f"An error occurred while reading config.json: {e}")
api_key = config_data.get("GOOGLE_API_KEY")
if not api_key or api_key == "YOUR_API_KEY_HERE":
showInfo("Please add your Google Cloud API key to config.json.")
field_names = config_data.get("FIELD_NAMES", {
"simplified": "Simplified",
"pinyin": "Pinyin",
"sentenceSimplified": "SentenceSimplified",
"sentencePinyin": "SentencePinyin.1",
"sentenceMeaning": "SentenceMeaning",
"sentenceAudio": "SentenceAudio"
})
def get_gcloud_tts_audio(text, lang_code='cmn-CN', voice_name='cmn-CN-Wavenet-D'):
"""
Generates a speech audio file from a given text using Google Cloud TTS.
Returns the filename if successful, otherwise None.
This function has been modified to return None on failure instead of calling showInfo,
as it runs on a background thread.
"""
if not api_key:
return None
endpoint = f"https://texttospeech.googleapis.com/v1/text:synthesize?key={api_key}"
# Construct the JSON payload for the API request
payload = {
"input": {
"text": text
},
"voice": {
"languageCode": lang_code,
"name": voice_name
},
"audioConfig": {
"audioEncoding": "MP3"
}
}
try:
response = requests.post(endpoint, json=payload)
response.raise_for_status() # Raises an HTTPError for bad responses (4xx or 5xx)
audio_content_base64 = response.json().get('audioContent')
if not audio_content_base64:
return None
audio_content = base64.b64decode(audio_content_base64)
# Create a unique filename for the audio file
audio_filename = f"gcloud_tts_audio_{hash(text)}.mp3"
media_path = os.path.join(mw.col.media.dir(), audio_filename)
with open(media_path, "wb") as f:
f.write(audio_content)
return audio_filename
except requests.exceptions.RequestException as e:
# Pass the error to the calling function to handle
return None
except Exception as e:
# Pass the error to the calling function to handle
return None
def call_llm_api(words_list):
"""
Calls the Gemini API to generate sentences and translations for a list of words.
This function has been modified to return a tuple of (result, error_message)
for better error reporting.
"""
if not api_key:
return None, "No API key found in config.json."
# Construct the prompt for the LLM with the new instructions
prompt_text = (
"You are a helpful language learning tutor. "
"Create short and concise Chinese sentences and their English translations "
"for the following Chinese words. The sentences should be rich in context, providing strong clues so that if a learner knows all the other words, they can infer the meaning of the target word. "
"Prioritize creating sentences that are vivid and memorable, suitable for flashcards for an intermediate learner (HSK 4-5 level). "
"If possible, combine as many of the provided words as you can into single sentences, "
"but do not sacrifice memorability or contextual richness for this. "
"Do not include any Pinyin or other extra text. "
"Please return the output as a JSON array where each object has three fields: "
"'word' (the Chinese word you were given), 'sentence' (the Chinese sentence you generated), "
"and 'translation' (the English translation of that sentence). "
"The response must contain ONLY the JSON array.\n"
"Here is the list of words:\n" + "\n".join(words_list)
)
api_url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={api_key}"
# Define the JSON schema for the response
payload = {
"contents": [{"parts": [{"text": prompt_text}]}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": {
"type": "ARRAY",
"items": {
"type": "OBJECT",
"properties": {
"word": {"type": "STRING"},
"sentence": {"type": "STRING"},
"translation": {"type": "STRING"}
},
"propertyOrdering": ["word", "sentence", "translation"]
}
}
},
}
max_retries = 5
retry_delay = 5 # seconds
for attempt in range(max_retries):
try:
response = requests.post(api_url, json=payload, timeout=180)
response.raise_for_status()
# The API returns a JSON response wrapped in text
content_text = response.json()['candidates'][0]['content']['parts'][0]['text']
# Attempt to parse the JSON string
return json.loads(content_text), None
except requests.exceptions.HTTPError as err:
if err.response.status_code == 503 and attempt < max_retries - 1:
showInfo(
f"Received 503 error, retrying in {retry_delay} seconds... (Attempt {attempt + 1}/{max_retries})")
time.sleep(retry_delay)
retry_delay *= 2 # Exponential backoff
else:
return None, f"HTTP Error: {err.response.status_code} - {err.response.text}"
except requests.exceptions.RequestException as err:
return None, f"Network Error: {err}"
except (json.JSONDecodeError, KeyError) as err:
return None, f"API Response Error: {err}"
return None, f"Failed to get a valid response after {max_retries} attempts."
def process_cards(notes_to_process):
"""
Processes all notes, updates them with generated sentences, and handles audio.
"""
# 1. Collect all words from the notes
words_list = []
for note in notes_to_process:
simplified_word = note[field_names.get('simplified')].strip()
if simplified_word:
words_list.append(simplified_word)
if not words_list:
return {"success_count": 0, "fail_count": 0, "failed_cards": []}
# 2. Call the LLM API to get sentences and translations in bulk
llm_results, llm_error = call_llm_api(words_list)
if llm_results is None:
return {"success_count": 0, "fail_count": len(notes_to_process),
"failed_cards": [f"Failed to get a valid response from the LLM. Error: {llm_error}"]}
# Create a dictionary for easy lookup
llm_dict = {item['word']: (item['sentence'], item['translation']) for item in llm_results}
success_count = 0
fail_count = 0
failed_cards = []
# Get the list of voices from the configuration
voices_to_use = config_data.get("voices")
if not voices_to_use or not isinstance(voices_to_use, list):
return {"success_count": 0, "fail_count": len(notes_to_process), "failed_cards": [
"Error: 'voices' key not found or is invalid in config.json. Please ensure it's a list of voice names."]}
# 3. Update each note with the LLM's response
for note in notes_to_process:
simplified_word = note[field_names.get('simplified')].strip()
if simplified_word in llm_dict:
try:
new_sentence, new_translation = llm_dict[simplified_word]
# Ensure the generated sentence actually contains the word
if simplified_word not in new_sentence:
fail_count += 1
failed_cards.append(
f"Card for '{simplified_word}' failed: Generated sentence does not contain the target word: '{new_sentence}'"
)
continue # Skip to the next note
# ----------------------------
# Generate Pinyin with tone marks and spaces using pypinyin
pinyin_words = []
words = jieba.cut(new_sentence)
for word in words:
pinyin_for_word = pinyin(word, style=Style.TONE)
pinyin_words.append("".join(p[0] for p in pinyin_for_word))
pinyin_sentence = " ".join(pinyin_words)
# Select a random voice from the list
voice_name = random.choice(voices_to_use)
audio_filename = get_gcloud_tts_audio(new_sentence, voice_name=voice_name)
if audio_filename:
note[field_names.get('sentenceSimplified')] = new_sentence
note[field_names.get('sentencePinyin')] = pinyin_sentence
note[field_names.get('sentenceMeaning')] = new_translation
note[field_names.get('sentenceAudio')] = f"[sound:{audio_filename}]"
mw.col.update_note(note)
success_count += 1
else:
fail_count += 1
# Pass a detailed error message for the summary screen
failed_cards.append(
f"Card for '{simplified_word}' failed: Failed to generate audio. Check your internet connection or API key.")
except Exception as e:
fail_count += 1
failed_cards.append(f"Card for '{simplified_word}' failed unexpectedly: {e}")
else:
fail_count += 1
failed_cards.append(f"Card for '{simplified_word}' failed: LLM did not provide a response for this word.")
return {
"success_count": success_count,
"fail_count": fail_count,
"failed_cards": failed_cards
}
def on_generate_sentences():
"""
This function is called when the menu item is clicked.
It initiates the background operation.
"""
try:
# Find the cards based on your criteria
card_ids = mw.col.find_cards("tag:GenerateSentence")
if not card_ids:
showInfo("No cards found with the 'GenerateSentence' tag.")
return
notes_to_process = [mw.col.get_card(cid).note() for cid in card_ids]
# Start the background operation to prevent UI freeze
op = QueryOp(
parent=mw,
op=lambda col: process_cards(notes_to_process),
success=lambda result: show_final_result(result),
)
op.run_in_background()
except Exception as e:
showInfo(f"An unexpected error occurred: {e}")
def show_final_result(result):
"""
Displays the final results to the user in a readable format.
"""
success_count = result["success_count"]
fail_count = result["fail_count"]
failed_cards = result["failed_cards"]
message = f"Operation complete!\n\nSuccessfully updated {success_count} cards."
if fail_count > 0:
message += f"\n\nFailed to update {fail_count} cards. The following errors were found:\n"
message += "\n".join(failed_cards)
showInfo(message)
# Create the menu item
action = QAction("Generate Sentences (G)", mw)
action.triggered.connect(on_generate_sentences)
mw.form.menuTools.addAction(action)
and here is the config (with my API key removed of course):
{
"FIELD_NAMES": {
"pinyin": "Pinyin",
"sentenceAudio": "SentenceAudio",
"sentenceMeaning": "SentenceMeaning",
"sentencePinyin": "SentencePinyin.1",
"sentenceSimplified": "SentenceSimplified",
"simplified": "Simplified"
},
"GOOGLE_API_KEY": "",
"max_sentence_length": 25,
"min_sentence_length": 5,
"speaking_rate": 0.8,
"voices": [
"cmn-CN-Chirp3-HD-Achernar",
"cmn-CN-Chirp3-HD-Achird",
"cmn-CN-Chirp3-HD-Algenib",
"cmn-CN-Chirp3-HD-Algieba",
"cmn-CN-Chirp3-HD-Alnilam",
"cmn-CN-Chirp3-HD-Aoede",
"cmn-CN-Chirp3-HD-Autonoe",
"cmn-CN-Chirp3-HD-Callirrhoe",
"cmn-CN-Chirp3-HD-Charon",
"cmn-CN-Chirp3-HD-Despina",
"cmn-CN-Chirp3-HD-Enceladus",
"cmn-CN-Chirp3-HD-Erinome",
"cmn-CN-Chirp3-HD-Fenrir",
"cmn-CN-Chirp3-HD-Gacrux",
"cmn-CN-Chirp3-HD-Iapetus",
"cmn-CN-Chirp3-HD-Kore",
"cmn-CN-Chirp3-HD-Laomedeia",
"cmn-CN-Chirp3-HD-Leda",
"cmn-CN-Chirp3-HD-Orus",
"cmn-CN-Chirp3-HD-Puck",
"cmn-CN-Chirp3-HD-Pulcherrima",
"cmn-CN-Chirp3-HD-Rasalgethi",
"cmn-CN-Chirp3-HD-Sadachbia",
"cmn-CN-Chirp3-HD-Sadaltager",
"cmn-CN-Chirp3-HD-Schedar",
"cmn-CN-Chirp3-HD-Sulafat",
"cmn-CN-Chirp3-HD-Umbriel",
"cmn-CN-Chirp3-HD-Vindemiatrix",
"cmn-CN-Chirp3-HD-Zephyr",
"cmn-CN-Chirp3-HD-Zubenelgenubi"
]
}
I hope this is helpful.