-
-
Notifications
You must be signed in to change notification settings - Fork 135
Add Wiktionary word definition skill #379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package org.stypox.dicio.skills.definition | ||
|
|
||
| import android.content.Context | ||
| import androidx.compose.material.icons.Icons | ||
| import androidx.compose.material.icons.filled.MenuBook | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.ui.graphics.vector.rememberVectorPainter | ||
| import org.dicio.skill.context.SkillContext | ||
| import org.dicio.skill.skill.Skill | ||
| import org.dicio.skill.skill.SkillInfo | ||
| import org.stypox.dicio.R | ||
| import org.stypox.dicio.sentences.Sentences | ||
|
|
||
| object DefinitionInfo : SkillInfo("definition") { | ||
| override fun name(context: Context) = | ||
| context.getString(R.string.skill_name_definition) | ||
|
|
||
| override fun sentenceExample(context: Context) = | ||
| context.getString(R.string.skill_sentence_example_definition) | ||
|
|
||
| @Composable | ||
| override fun icon() = | ||
| rememberVectorPainter(Icons.Default.MenuBook) | ||
|
|
||
| override fun isAvailable(ctx: SkillContext): Boolean { | ||
| return Sentences.Definition[ctx.sentencesLanguage] != null | ||
| } | ||
|
|
||
| override fun build(ctx: SkillContext): Skill<*> { | ||
| return DefinitionSkill(DefinitionInfo, Sentences.Definition[ctx.sentencesLanguage]!!) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| package org.stypox.dicio.skills.definition | ||
|
|
||
| import androidx.compose.foundation.layout.Column | ||
| import androidx.compose.foundation.layout.Spacer | ||
| import androidx.compose.foundation.layout.fillMaxWidth | ||
| import androidx.compose.foundation.layout.height | ||
| import androidx.compose.material3.MaterialTheme | ||
| import androidx.compose.material3.Text | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.ui.Modifier | ||
| import androidx.compose.ui.text.font.FontWeight | ||
| import androidx.compose.ui.unit.dp | ||
| import org.dicio.skill.context.SkillContext | ||
| import org.dicio.skill.skill.SkillOutput | ||
| import org.stypox.dicio.R | ||
| import org.stypox.dicio.io.graphical.HeadlineSpeechSkillOutput | ||
| import org.stypox.dicio.util.getString | ||
|
|
||
| sealed interface DefinitionOutput : SkillOutput { | ||
| data class Success( | ||
| val word: String, | ||
| val definitions: List<PartOfSpeechDefinition> | ||
| ) : DefinitionOutput { | ||
| override fun getSpeechOutput(ctx: SkillContext): String { | ||
| val firstDefinition = definitions.firstOrNull()?.definitions?.firstOrNull() | ||
| return if (firstDefinition != null) { | ||
| ctx.getString( | ||
| R.string.skill_definition_found, | ||
| word, | ||
| definitions.first().partOfSpeech, | ||
| firstDefinition | ||
| ) | ||
| } else { | ||
| ctx.getString(R.string.skill_definition_not_found, word) | ||
| } | ||
| } | ||
|
|
||
| @Composable | ||
| override fun GraphicalOutput(ctx: SkillContext) { | ||
| Column(modifier = Modifier.fillMaxWidth()) { | ||
| Text( | ||
| text = word, | ||
| style = MaterialTheme.typography.headlineMedium, | ||
| fontWeight = FontWeight.Bold | ||
| ) | ||
| Spacer(modifier = Modifier.height(12.dp)) | ||
|
|
||
| definitions.forEach { posDefinition -> | ||
| Text( | ||
| text = posDefinition.partOfSpeech, | ||
| style = MaterialTheme.typography.titleMedium, | ||
| fontWeight = FontWeight.SemiBold, | ||
| color = MaterialTheme.colorScheme.primary | ||
| ) | ||
| Spacer(modifier = Modifier.height(4.dp)) | ||
|
|
||
| posDefinition.definitions.forEachIndexed { index, definition -> | ||
| Text( | ||
| text = "${index + 1}. $definition", | ||
| style = MaterialTheme.typography.bodyMedium, | ||
| modifier = Modifier.fillMaxWidth() | ||
| ) | ||
| Spacer(modifier = Modifier.height(4.dp)) | ||
| } | ||
| Spacer(modifier = Modifier.height(8.dp)) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| data class NotFound( | ||
| val word: String | ||
| ) : DefinitionOutput, HeadlineSpeechSkillOutput { | ||
| override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( | ||
| R.string.skill_definition_not_found, word | ||
| ) | ||
| } | ||
|
|
||
| data class NetworkError( | ||
| val word: String | ||
| ) : DefinitionOutput, HeadlineSpeechSkillOutput { | ||
| override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( | ||
| R.string.skill_definition_network_error, word | ||
| ) | ||
| } | ||
|
|
||
| data class ParseError( | ||
| val word: String | ||
| ) : DefinitionOutput, HeadlineSpeechSkillOutput { | ||
| override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( | ||
| R.string.skill_definition_parse_error, word | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| data class PartOfSpeechDefinition( | ||
| val partOfSpeech: String, | ||
| val definitions: List<String> | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| package org.stypox.dicio.skills.definition | ||
|
|
||
| import org.dicio.skill.context.SkillContext | ||
| import org.dicio.skill.skill.SkillInfo | ||
| import org.dicio.skill.skill.SkillOutput | ||
| import org.dicio.skill.standard.StandardRecognizerData | ||
| import org.dicio.skill.standard.StandardRecognizerSkill | ||
| import org.json.JSONArray | ||
| import org.json.JSONException | ||
| import org.json.JSONObject | ||
| import org.stypox.dicio.sentences.Sentences.Definition | ||
| import org.stypox.dicio.util.ConnectionUtils | ||
| import java.io.FileNotFoundException | ||
| import java.io.IOException | ||
| import java.util.Locale | ||
|
|
||
| class DefinitionSkill(correspondingSkillInfo: SkillInfo, data: StandardRecognizerData<Definition>) : | ||
| StandardRecognizerSkill<Definition>(correspondingSkillInfo, data) { | ||
|
|
||
| override suspend fun generateOutput(ctx: SkillContext, inputData: Definition): SkillOutput { | ||
| val word: String = when (inputData) { | ||
| is Definition.Query -> inputData.word ?: return DefinitionOutput.NotFound(word = "") | ||
| } | ||
|
|
||
| // Get language code from locale (e.g., "en", "fr", "de") | ||
| val languageCode = ctx.locale.language.lowercase(Locale.getDefault()) | ||
|
|
||
| // Build Wiktionary API URL based on user's locale | ||
| val apiUrl = "https://$languageCode.wiktionary.org/api/rest_v1/page/definition/" + | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know there aren't many other tests, but it might be worth writing a test that makes a request to this URL for every possible language supported by Dicio with some known word in that language (e.g. extracted from strings.xml), and checks whether the definition gets resolved correctly. |
||
| ConnectionUtils.percentEncode(word.trim()) | ||
|
|
||
| return try { | ||
| val definitionData = ConnectionUtils.getPageJson(apiUrl) | ||
| parseDefinitions(word, definitionData) | ||
| } catch (e: FileNotFoundException) { | ||
| // 404 - word not found in Wiktionary | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if the |
||
| DefinitionOutput.NotFound(word = word) | ||
| } catch (e: IOException) { | ||
| // Network error | ||
| DefinitionOutput.NetworkError(word = word) | ||
|
Comment on lines
+38
to
+40
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just let this fall through and be handled by the skill evaluator (it will display a nice error message for the network error) |
||
| } catch (e: JSONException) { | ||
| // Failed to parse response | ||
| DefinitionOutput.ParseError(word = word) | ||
| } | ||
| } | ||
|
|
||
| private fun parseDefinitions(word: String, data: JSONObject): SkillOutput { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might also be worth writing a test that loads "https://$languageCode.wiktionary.org/api/rest_v1/page/definition/..." and makes sure it is parsed as expected. |
||
| try { | ||
| // Wiktionary API returns language-specific definitions | ||
| // The structure is: { "en": [ { "partOfSpeech": "...", "definitions": [...] }, ... ] } | ||
| // or sometimes just an array at the root level | ||
|
|
||
| val languageKeys = data.keys() | ||
| if (!languageKeys.hasNext()) { | ||
| return DefinitionOutput.NotFound(word = word) | ||
| } | ||
|
|
||
| // Get the first language's definitions (usually matches the Wiktionary language) | ||
| val firstLanguageKey = languageKeys.next() | ||
| val definitionsArray: JSONArray = data.getJSONArray(firstLanguageKey) | ||
|
|
||
| if (definitionsArray.length() == 0) { | ||
| return DefinitionOutput.NotFound(word = word) | ||
| } | ||
|
|
||
| val posDefinitions = mutableListOf<PartOfSpeechDefinition>() | ||
|
|
||
| // Parse each part of speech | ||
| for (i in 0 until definitionsArray.length()) { | ||
| val posObject = definitionsArray.getJSONObject(i) | ||
| val partOfSpeech = posObject.optString("partOfSpeech", "Unknown") | ||
| val defsArray = posObject.optJSONArray("definitions") | ||
|
|
||
| if (defsArray != null && defsArray.length() > 0) { | ||
| val definitions = mutableListOf<String>() | ||
|
|
||
| // Only take the first 3 definitions per part of speech | ||
| val maxDefinitions = minOf(3, defsArray.length()) | ||
| for (j in 0 until maxDefinitions) { | ||
| val defObject = defsArray.getJSONObject(j) | ||
| val definition = defObject.optString("definition", "") | ||
| if (definition.isNotEmpty()) { | ||
| // Clean up the definition text (remove HTML tags if any) | ||
| definitions.add(cleanDefinitionText(definition)) | ||
| } | ||
| } | ||
|
|
||
| if (definitions.isNotEmpty()) { | ||
| posDefinitions.add( | ||
| PartOfSpeechDefinition( | ||
| partOfSpeech = partOfSpeech, | ||
| definitions = definitions | ||
| ) | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return if (posDefinitions.isNotEmpty()) { | ||
| DefinitionOutput.Success(word = word, definitions = posDefinitions) | ||
| } else { | ||
| DefinitionOutput.NotFound(word = word) | ||
| } | ||
| } catch (e: JSONException) { | ||
| return DefinitionOutput.ParseError(word = word) | ||
| } | ||
|
Comment on lines
+104
to
+106
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mmmh you have double try-catch. Remove this one and let it be handled in the other |
||
| } | ||
|
|
||
| private fun cleanDefinitionText(text: String): String { | ||
| // Remove HTML tags and extra whitespace | ||
| return text | ||
| .replace(Regex("<[^>]*>"), "") // Remove HTML tags | ||
| .replace(Regex("\\s+"), " ") // Normalize whitespace | ||
| .trim() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| query: | ||
| - define .word. | ||
| - (what does|what is|what s|whats) .word. mean|means? | ||
| - (what is|what s|whats) the? (definition|meaning) (of|for) .word. | ||
|
Comment on lines
+3
to
+4
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is likely not what you meant. |
||
| - (the? definition|meaning) of .word. | ||
| - .word. definition|meaning | ||
| - look up .word. | ||
| - (can you )?(define|explain) .word. (for me|to me)? | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's already lowercase, see https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Locale.html#getLanguage() and the Locale constructors.