2020
2121import com .google .adk .events .Event ;
2222import com .google .adk .sessions .Session ;
23+ import com .google .common .base .CharMatcher ;
2324import com .google .common .collect .ImmutableList ;
2425import com .google .common .collect .ImmutableSet ;
2526import com .google .genai .types .Part ;
2627import io .reactivex .rxjava3 .core .Completable ;
2728import io .reactivex .rxjava3 .core .Single ;
29+ import java .text .Normalizer ;
2830import java .time .Instant ;
2931import java .util .ArrayList ;
30- import java .util .Collections ;
31- import java .util .HashSet ;
3232import java .util .List ;
3333import java .util .Locale ;
3434import java .util .Map ;
35- import java .util .Set ;
3635import java .util .concurrent .ConcurrentHashMap ;
3736import java .util .regex .Matcher ;
3837import java .util .regex .Pattern ;
4443 */
4544public final class InMemoryMemoryService implements BaseMemoryService {
4645
47- // Pattern to extract words, matching the Python version .
48- private static final Pattern WORD_PATTERN = Pattern .compile ("[A-Za-z ]+" );
46+ // Letters, numbers and _, as Python's \w+ matches them .
47+ private static final Pattern WORD_PATTERN = Pattern .compile ("[\\ p{L} \\ p{N}_ ]+" );
4948
5049 /** Keys are "app_name/user_id", values are maps of "session_id" to a list of events. */
5150 private final Map <String , Map <String , List <Event >>> sessionEvents ;
@@ -91,8 +90,7 @@ public Single<SearchMemoryResponse> searchMemory(String appName, String userId,
9190
9291 Map <String , List <Event >> userSessions = sessionEvents .get (key );
9392
94- ImmutableSet <String > wordsInQuery =
95- ImmutableSet .copyOf (query .toLowerCase (Locale .ROOT ).split ("\\ s+" ));
93+ ImmutableSet <String > wordsInQuery = extractWordsLower (query );
9694
9795 List <MemoryEntry > matchingMemories = new ArrayList <>();
9896
@@ -102,22 +100,29 @@ public Single<SearchMemoryResponse> searchMemory(String appName, String userId,
102100 continue ;
103101 }
104102
105- Set <String > wordsInEvent = new HashSet <>();
103+ List <String > eventTexts = new ArrayList <>();
106104 for (Part part : event .content ().get ().parts ().get ()) {
107- String text = part .text ().orElse ("" );
108- if (!text .isEmpty ()) {
109- Matcher matcher = WORD_PATTERN .matcher (text );
110- while (matcher .find ()) {
111- wordsInEvent .add (matcher .group ().toLowerCase (Locale .ROOT ));
112- }
113- }
105+ part .text ().filter (text -> !text .isEmpty ()).ifPresent (eventTexts ::add );
114106 }
107+ String eventText = String .join (" " , eventTexts );
108+ ImmutableSet <String > wordsInEvent = extractSearchableWords (eventText );
115109
116110 if (wordsInEvent .isEmpty ()) {
117111 continue ;
118112 }
119113
120- if (!Collections .disjoint (wordsInQuery , wordsInEvent )) {
114+ // A non-ASCII query word also matches inside the text, as Japanese and Chinese put no
115+ // spaces between words.
116+ String eventTextLower =
117+ Normalizer .normalize (eventText , Normalizer .Form .NFC ).toLowerCase (Locale .ROOT );
118+ boolean matches =
119+ wordsInQuery .stream ()
120+ .anyMatch (
121+ word ->
122+ wordsInEvent .contains (word )
123+ || (!CharMatcher .ascii ().matchesAllOf (word )
124+ && eventTextLower .contains (word )));
125+ if (matches ) {
121126 MemoryEntry memory =
122127 MemoryEntry .builder ()
123128 .content (event .content ().get ())
@@ -135,6 +140,58 @@ public Single<SearchMemoryResponse> searchMemory(String appName, String userId,
135140 });
136141 }
137142
143+ /** Extracts words from a string and converts them to lowercase. */
144+ private static ImmutableSet <String > extractWordsLower (String text ) {
145+ ImmutableSet .Builder <String > words = ImmutableSet .builder ();
146+ Matcher matcher = WORD_PATTERN .matcher (Normalizer .normalize (text , Normalizer .Form .NFC ));
147+ while (matcher .find ()) {
148+ words .add (matcher .group ().toLowerCase (Locale .ROOT ));
149+ }
150+ return words .build ();
151+ }
152+
153+ /**
154+ * Extracts the words an event can be matched on, in lowercase: the words of {@link
155+ * #extractWordsLower} plus, for a word that mixes Latin and non-Latin characters, each of its
156+ * single-script runs. Japanese and Chinese are written without spaces, so {@code 私はPythonを使う} is
157+ * a single word, and splitting it where the script changes lets a query for {@code python} match
158+ * while a partial word such as {@code thon} still does not.
159+ */
160+ private static ImmutableSet <String > extractSearchableWords (String text ) {
161+ ImmutableSet <String > words = extractWordsLower (text );
162+ ImmutableSet .Builder <String > searchable = ImmutableSet .<String >builder ().addAll (words );
163+ for (String word : words ) {
164+ if (CharMatcher .ascii ().matchesAllOf (word )) {
165+ continue ;
166+ }
167+ int runStart = 0 ;
168+ boolean runIsLatin = isLatin (word .codePointAt (0 ));
169+ for (int i = 0 ; i < word .length (); i += Character .charCount (word .codePointAt (i ))) {
170+ boolean latin = isLatin (word .codePointAt (i ));
171+ if (latin != runIsLatin ) {
172+ searchable .add (word .substring (runStart , i ));
173+ runStart = i ;
174+ runIsLatin = latin ;
175+ }
176+ }
177+ searchable .add (word .substring (runStart ));
178+ }
179+ return searchable .build ();
180+ }
181+
182+ /**
183+ * ASCII letters, digits and {@code _}, or a character above ASCII whose Unicode name starts with
184+ * {@code LATIN}, as Python checks it. Unlike the Latin script, this leaves out characters such as
185+ * {@code º} and {@code ª}.
186+ */
187+ private static boolean isLatin (int codePoint ) {
188+ if (codePoint < 0x80 ) {
189+ return Character .isLetterOrDigit (codePoint ) || codePoint == '_' ;
190+ }
191+ String name = Character .getName (codePoint );
192+ return name != null && name .startsWith ("LATIN" );
193+ }
194+
138195 private String formatTimestamp (long timestamp ) {
139196 return Instant .ofEpochMilli (timestamp ).toString ();
140197 }
0 commit comments