Skip to content

Commit 9cb4cb8

Browse files
authored
Add AI analysis of collocations feature (#96)
This change allows eligible users to get AI explanations and generate example sentences based on the collocations present in the sankey diagrams. Such collocations often help illustrate grammar rules or common speech patterns. This is similar to TrieLingual albeit in a different diagram form. The prompts could likely use some tweaking, and prices for Gemini 2.5 flash seem comparable to Gemini 2.0 flash, so upgrading seems to make sense; those changes will be done in a separate commit.
1 parent a259dcf commit 9cb4cb8

File tree

9 files changed

+168
-19
lines changed

9 files changed

+168
-19
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
model: vertexai/gemini-2.0-flash-001
3+
input:
4+
schema:
5+
collocation: string
6+
output:
7+
schema: AnalyzeCollocationSchema
8+
---
9+
{{role "system"}}
10+
You are a helpful Chinese teacher for speakers of English who want to learn Chinese. You speak naturally, and you provide helpful sentences that illustrate how to use Chinese vocabulary.
11+
{{role "user"}}
12+
Please generate three example Chinese sentences, each with a separate English translation and pinyin, that uses the phrase "{{collocation}}":
13+
Each sentence must include "{{collocation}}".
14+
15+
Please also translate "{{collocation}}" to English and provide a plain-text explanation of how such a phrase would be used.

functions/src/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
imageAnalysisSchema,
1010
chineseSentenceGenerationSchema,
1111
generateChineseSentencesInputSchema,
12+
analyzeCollocationSchema,
1213
} from "./schema";
1314

1415
let firebaseApp: admin.app.App;
@@ -130,3 +131,29 @@ const generateChineseSentencesFlow = ai.defineFlow({
130131
});
131132

132133
export const generateChineseSentences = onCallGenkit(generateChineseSentencesFlow);
134+
135+
const AnalyzeCollocationSchema = ai.defineSchema('AnalyzeCollocationSchema', analyzeCollocationSchema);
136+
const analyzeCollocationPrompt = ai.prompt<z.ZodTypeAny, typeof AnalyzeCollocationSchema>('analyze-collocation');
137+
138+
const analyzeCollocationFlow = ai.defineFlow({
139+
name: "analyzeCollocation",
140+
inputSchema: z.string(),
141+
outputSchema: analyzeCollocationSchema,
142+
}, async (collocation, { context }) => {
143+
if (!firebaseApp) {
144+
firebaseApp = admin.initializeApp();
145+
}
146+
const isAuthorized = await isUserAuthorized(context);
147+
if (!isAuthorized) {
148+
throw new HttpsError("permission-denied", "user not authorized");
149+
}
150+
collocation = collocation.replaceAll(' ', '');
151+
const { output } = await analyzeCollocationPrompt({ collocation });
152+
if (!output) {
153+
throw new HttpsError("internal", 'oh no, the model like, failed?');
154+
}
155+
return output;
156+
},
157+
);
158+
159+
export const analyzeCollocation = onCallGenkit(analyzeCollocationFlow);

functions/src/schema.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,19 @@ export const generateChineseSentencesInputSchema = z.object({
4242
definitions: z.array(z.string()),
4343
});
4444

45+
const sentenceSchema = z.array(
46+
z.object({
47+
chineseTextWithoutPinyin: z.string(),
48+
pinyin: z.string(),
49+
englishTranslation: z.string(),
50+
}));
51+
4552
export const chineseSentenceGenerationSchema = z.object({
46-
sentences: z.array(
47-
z.object({
48-
chineseTextWithoutPinyin: z.string(),
49-
pinyin: z.string(),
50-
englishTranslation: z.string(),
51-
})),
53+
sentences: sentenceSchema,
54+
});
55+
56+
export const analyzeCollocationSchema = z.object({
57+
englishTranslation: z.string(),
58+
plainTextExplanation: z.string(),
59+
sentences: sentenceSchema,
5260
});

functions/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"outDir": "lib",
1010
"sourceMap": true,
1111
"strict": true,
12-
"target": "es2017",
12+
"target": "es2021",
1313
"skipLibCheck": true,
1414
"esModuleInterop": true,
1515
"moduleResolution": "nodenext",

public/css/hanzi-graph.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,29 @@ https://developer.mozilla.org/en-US/docs/Web/API/File_API/Using_files_from_web_a
342342
margin: 0 28px 2px 4px;
343343
}
344344

345+
.inline-button-container {
346+
text-align: center;
347+
}
348+
349+
.inline-menu-item {
350+
line-height: 40px;
351+
font-weight: bold;
352+
cursor: pointer;
353+
width: fit-content;
354+
padding: 0 8px;
355+
background: var(--popover-background-color);
356+
border-radius: 24px;
357+
border: var(--border);
358+
margin: 6px;
359+
display: inline-block;
360+
}
361+
362+
.inline-menu-item .ai-icon {
363+
position: relative;
364+
top: 2px;
365+
margin: 0 10px 0 4px
366+
}
367+
345368
.open-button {
346369
opacity: 0.5;
347370
}

public/js/modules/data-layer.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,21 @@ async function generateChineseSentences(word, definitions) {
619619
return tokenizedSentences;
620620
}
621621

622+
async function analyzeCollocation(collocation) {
623+
const functions = getFunctions();
624+
// connectFunctionsEmulator(functions, "127.0.0.1", 5001);
625+
const analyzeCollocation = httpsCallable(functions, 'analyzeCollocation');
626+
const aiData = await analyzeCollocation(collocation);
627+
// same explanation of goofiness as `generateChineseSentences`. In the collocation case, we both generate sentences
628+
// and get an explanation back. Tokenize the sentences, then return the whole thing.
629+
const tokenizedSentences = await new Promise((resolve, reject) => {
630+
document.dispatchEvent(new CustomEvent('sentence-generation-response', { detail: { aiData, collocation, resolve, reject } }));
631+
});
632+
// i know i shouldn't
633+
aiData.data['sentences'] = tokenizedSentences;
634+
return aiData;
635+
}
636+
622637
function isAiEligible() {
623638
return aiEligible;
624639
}
@@ -631,4 +646,4 @@ async function analyzeImage(base64ImageContents) {
631646
return result;
632647
}
633648

634-
export { writeExploreState, readExploreState, writeOptionState, readOptionState, registerCallback, saveStudyList, addCard, inStudyList, countWordsWithoutCards, getStudyList, isFlashCardUser, removeFromStudyList, findOtherCards, updateCard, recordEvent, getStudyResults, explainChineseSentence, translateEnglish, analyzeImage, generateChineseSentences, isAiEligible, hasCardWithWord, initialize, studyResult, dataTypes, cardTypes }
649+
export { writeExploreState, readExploreState, writeOptionState, readOptionState, registerCallback, saveStudyList, addCard, inStudyList, countWordsWithoutCards, getStudyList, isFlashCardUser, removeFromStudyList, findOtherCards, updateCard, recordEvent, getStudyResults, explainChineseSentence, translateEnglish, analyzeImage, generateChineseSentences, analyzeCollocation, isAiEligible, hasCardWithWord, initialize, studyResult, dataTypes, cardTypes }

public/js/modules/dom.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,12 @@ function writeSeoMetaTags(urlState, graphDisplay) {
3535
}
3636
}
3737

38-
export { hanziBox, notFoundElement, walkThrough, examplesList, searchControl, writeSeoMetaTags }
38+
function createLoadingDots() {
39+
const loadingDots = document.createElement('div');
40+
loadingDots.classList.add('loading-dots');
41+
// there uh....there's four dots, ok?
42+
loadingDots.innerHTML = '<div></div><div></div><div></div><div></div>';
43+
return loadingDots;
44+
}
45+
46+
export { hanziBox, notFoundElement, walkThrough, examplesList, searchControl, createLoadingDots, writeSeoMetaTags }

public/js/modules/explore.js

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { writeExploreState, addCard, inStudyList, isFlashCardUser, explainChineseSentence, generateChineseSentences, isAiEligible, countWordsWithoutCards, hasCardWithWord, registerCallback, dataTypes } from "./data-layer.js";
2-
import { hanziBox, notFoundElement, walkThrough, examplesList } from "./dom.js";
2+
import { hanziBox, notFoundElement, walkThrough, examplesList, createLoadingDots } from "./dom.js";
33
import { getActiveGraph, getPartition } from "./options.js";
44
import { renderCoverageGraph } from "./coverage-graph"
55
import { diagramKeys, switchDiagramView, showNotification } from "./ui-orchestrator.js";
@@ -949,6 +949,19 @@ function renderExplanation(explanation, container) {
949949
container.appendChild(explanationContainer);
950950
}
951951

952+
// TODO combine with renderExplanation (or like, just use a framework)
953+
function renderStandaloneTranslation(translation, term, container) {
954+
const translationHeader = document.createElement('h3');
955+
translationHeader.innerText = `Translation`;
956+
957+
const translationContainer = document.createElement('div');
958+
translationContainer.classList.add('emphasized-but-not-that-emphasized');
959+
translationContainer.innerText = `"${term}" translates as: "${translation}".`;
960+
961+
container.appendChild(translationHeader);
962+
container.appendChild(translationContainer);
963+
}
964+
952965
function renderAiExplanationResponse(words, response, container) {
953966
// TODO: error states
954967
const data = response.data;
@@ -974,14 +987,6 @@ function renderAiExplanationResponse(words, response, container) {
974987
container.appendChild(grammarContainer);
975988
}
976989

977-
function createLoadingDots() {
978-
const loadingDots = document.createElement('div');
979-
loadingDots.classList.add('loading-dots');
980-
// there uh....there's four dots, ok?
981-
loadingDots.innerHTML = '<div></div><div></div><div></div><div></div>';
982-
return loadingDots;
983-
}
984-
985990
let setupExamples = function (words, type, skipState, allowExplain, aiData) {
986991
currentExamples = {};
987992
currentDefinitions = {};
@@ -1162,6 +1167,22 @@ let initialize = function () {
11621167
// resizing the screen throws off positioning of the menu...just hide it
11631168
menuPopover.hidePopover();
11641169
});
1170+
// TODO this does not belong here...move the AI stuff to its own module, and common rendering functions
1171+
// to another.
1172+
document.addEventListener('collocation-analysis-response', function (event) {
1173+
const explanationContainer = document.createElement('div');
1174+
const translationContainer = document.createElement('div');
1175+
explanationContainer.classList.add('ai-explanation');
1176+
renderStandaloneTranslation(event.detail.aiData.data.englishTranslation, event.detail.collocation, translationContainer);
1177+
explanationContainer.appendChild(translationContainer);
1178+
const explanation = event.detail.aiData.data.plainTextExplanation;
1179+
event.detail.aiContainer.appendChild(explanationContainer);
1180+
renderExplanation(explanation, explanationContainer);
1181+
const exampleContainer = document.createElement('ul');
1182+
exampleContainer.classList.add('examples');
1183+
event.detail.aiContainer.appendChild(exampleContainer);
1184+
setupExampleElements(null, event.detail.aiData.data.sentences, exampleContainer, 'ai');
1185+
});
11651186
voice = getVoice();
11661187
fetchStats();
11671188
registerCallback(dataTypes.studyList, function () {

public/js/modules/flow-diagram.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { sankey, sankeyLinkHorizontal, sankeyCenter, sankeyJustify, sankeyRight,
33
import { map, schemeTableau10, union, scaleOrdinal, format as d3format, create } from "d3";
44
import { getPartition, getActiveGraph } from "./options";
55
import { faqTypes, showFaq } from "./faq";
6+
import { isAiEligible, analyzeCollocation } from "./data-layer";
7+
import { createLoadingDots } from "./dom";
68

79
function addToTrie(trie, collocation, count, term, maxDepth) {
810
let words = collocation.split(' ');
@@ -132,7 +134,7 @@ function renderCollocationData(term, collocations, nextSibling, container) {
132134
let description = document.createElement('p');
133135
description.className = 'collocations-detail';
134136
// TODO: assumption of ranks being present ok for now, but should be switched (well, a couple refactors would be good there)
135-
description.innerHTML = `When you see <span class="emphasized freq${getFrequencyLevel(wordSet[term], getActiveGraph().ranks)}">${term}</span>, it's often used with:`;
137+
description.innerHTML = `<span class="emphasized freq${getFrequencyLevel(wordSet[term], getActiveGraph().ranks)}">${term}</span> is often used with:`;
136138
for (const collocation of sorted) {
137139
let collocationsContainer = document.createElement('p');
138140
collocationsContainer.classList.add('collocation');
@@ -158,6 +160,10 @@ async function renderUsageDiagram(term, container) {
158160
// TODO(refactor): consolidate explanation classes
159161
explanation.classList.add('flow-explanation');
160162
container.appendChild(explanation);
163+
// will be empty unless the user is eligible
164+
const aiContainer = document.createElement('div');
165+
aiContainer.classList.add('inline-button-container');
166+
container.appendChild(aiContainer);
161167
explanation.innerText = 'Loading...';
162168
let count = 0;
163169
let loadingIndicator = setInterval(function () {
@@ -203,6 +209,32 @@ async function renderUsageDiagram(term, container) {
203209
nodeAlign: 'center',
204210
linkTitle: d => `${elements.labels[d.source.id]} ${elements.labels[d.target.id]}: ${d.value}`,
205211
linkClickHandler: (d, i) => {
212+
if (isAiEligible()) {
213+
aiContainer.innerHTML = '';
214+
aiContainer.classList.remove('ai-explanation-container');
215+
aiContainer.classList.add('inline-button-container');
216+
const collocationsAtClickedNode = elements.collocations[i.id];
217+
for (const collocation of collocationsAtClickedNode) {
218+
const collocationSentencesContainer = document.createElement('div');
219+
collocationSentencesContainer.classList.add('inline-menu-item');
220+
const aiIcon = document.createElement('span');
221+
aiIcon.classList.add('ai-icon');
222+
const sentenceButton = document.createElement('span');
223+
sentenceButton.innerText = `Analyze "${collocation}"`;
224+
collocationSentencesContainer.appendChild(aiIcon);
225+
collocationSentencesContainer.appendChild(sentenceButton);
226+
collocationSentencesContainer.addEventListener('click', async function () {
227+
aiContainer.innerHTML = '';
228+
aiContainer.classList.remove('inline-button-container');
229+
aiContainer.appendChild(createLoadingDots());
230+
const aiData = await analyzeCollocation(collocation);
231+
aiContainer.innerHTML = '';
232+
aiContainer.classList.add('ai-explanation-container');
233+
document.dispatchEvent(new CustomEvent('collocation-analysis-response', { detail: { aiData, term, collocation, aiContainer } }));
234+
});
235+
aiContainer.appendChild(collocationSentencesContainer);
236+
}
237+
}
206238
getCollocations(elements.labels[i.id]);
207239
switchDiagramView(diagramKeys.main);
208240
document.dispatchEvent(new CustomEvent('graph-update', { detail: elements.labels[i.id] }));

0 commit comments

Comments
 (0)