99import com .fasterxml .jackson .databind .node .ArrayNode ;
1010import com .fasterxml .jackson .databind .node .ObjectNode ;
1111import java .io .IOException ;
12+ import java .net .URLDecoder ;
13+ import java .net .URLEncoder ;
14+ import java .nio .charset .StandardCharsets ;
1215import java .util .*;
1316import okhttp3 .*;
1417import org .slf4j .Logger ;
@@ -50,7 +53,7 @@ public class LangCacheSemanticCache {
5053 private static final MediaType JSON = MediaType .get ("application/json; charset=utf-8" );
5154
5255 /**
53- * Translation map for encoding problematic attribute characters .
56+ * URL percent-encoding for attribute values .
5457 *
5558 * <p>LangCache service rejects or mishandles certain characters in attribute values:
5659 *
@@ -61,70 +64,35 @@ public class LangCacheSemanticCache {
6164 * <li>Question mark (?) - U+003F: Causes filtering failures
6265 * </ul>
6366 *
64- * <p>We replace these with visually similar fullwidth Unicode variants that the service accepts.
67+ * <p>We use standard URL percent-encoding (RFC 3986) to handle these characters, replacing the
68+ * previous fullwidth Unicode character approach. This provides better compatibility and follows
69+ * standard web encoding practices.
6570 *
66- * <p>Port of redis-vl-python PR #437 & #438
71+ * <p>Port of redis-vl-python PR #442
6772 */
68- private static final Map <Character , Character > ENCODE_TRANS =
69- Map .of (
70- ',' , ',' , // U+FF0C FULLWIDTH COMMA
71- '/' , '∕' , // U+2215 DIVISION SLASH
72- '\\' , '\' , // U+FF3C FULLWIDTH REVERSE SOLIDUS
73- '?' , '?' // U+FF1F FULLWIDTH QUESTION MARK
74- );
7573
7674 /**
77- * Translation map for decoding attribute characters back to original form .
75+ * Encode a string attribute value for use with the LangCache service using URL percent-encoding .
7876 *
79- * <p>Reverses the encoding applied by ENCODE_TRANS so callers receive the original values.
80- */
81- private static final Map <Character , Character > DECODE_TRANS ;
82-
83- static {
84- // Build reverse translation map
85- Map <Character , Character > decode = new HashMap <>();
86- for (Map .Entry <Character , Character > entry : ENCODE_TRANS .entrySet ()) {
87- decode .put (entry .getValue (), entry .getKey ());
88- }
89- DECODE_TRANS = Collections .unmodifiableMap (decode );
90- }
91-
92- /**
93- * Encode a string attribute value for use with the LangCache service.
77+ * <p>Uses standard URL percent-encoding (RFC 3986) to encode problematic characters (comma,
78+ * slash, backslash, question mark). This keeps attribute values round-trippable and usable for
79+ * attribute filtering.
9480 *
95- * <p>Replaces problematic characters (comma, slash, backslash, question mark) with visually
96- * similar Unicode variants that LangCache accepts. This keeps attribute values round-trippable
97- * and usable for attribute filtering.
81+ * <p>URLEncoder.encode() with UTF-8 charset ensures all special characters are properly escaped
82+ * as %XX hex sequences (e.g., comma becomes %2C, slash becomes %2F).
9883 *
9984 * @param value The original attribute value
100- * @return The encoded value safe for LangCache
85+ * @return The URL percent- encoded value safe for LangCache
10186 */
10287 private static String encodeAttributeValue (String value ) {
10388 if (value == null || value .isEmpty ()) {
10489 return value ;
10590 }
10691
107- StringBuilder result = null ; // Lazy allocation
108- int length = value .length ();
109-
110- for (int i = 0 ; i < length ; i ++) {
111- char ch = value .charAt (i );
112- Character replacement = ENCODE_TRANS .get (ch );
113-
114- if (replacement != null ) {
115- // First problematic char found - allocate StringBuilder and copy prefix
116- if (result == null ) {
117- result = new StringBuilder (length );
118- result .append (value , 0 , i );
119- }
120- result .append (replacement );
121- } else if (result != null ) {
122- // Already building encoded string
123- result .append (ch );
124- }
125- }
126-
127- return result != null ? result .toString () : value ;
92+ // URLEncoder.encode with UTF-8 uses application/x-www-form-urlencoded format
93+ // which replaces spaces with '+'. We want percent-encoding (RFC 3986) which uses %20.
94+ // The quote() function in Python uses percent-encoding with safe='', so we match that.
95+ return URLEncoder .encode (value , StandardCharsets .UTF_8 ).replace ("+" , "%20" );
12896 }
12997
13098 /**
@@ -164,40 +132,24 @@ private static Map<String, Object> encodeAttributes(Map<String, Object> attribut
164132 }
165133
166134 /**
167- * Decode a string attribute value returned from the LangCache service.
135+ * Decode a string attribute value returned from the LangCache service using URL percent-decoding.
136+ *
137+ * <p>Reverses {@link #encodeAttributeValue}, translating percent-encoded sequences back to their
138+ * original characters so callers see the original values they stored.
168139 *
169- * <p>Reverses {@link #encodeAttributeValue}, translating fullwidth characters back to their ASCII
170- * counterparts so callers see the original values they stored .
140+ * <p>URLDecoder.decode() with UTF-8 charset converts %XX hex sequences back to original
141+ * characters (e.g., %2C becomes comma, %2F becomes slash) .
171142 *
172- * @param value The encoded attribute value from LangCache
143+ * @param value The URL percent- encoded attribute value from LangCache
173144 * @return The decoded original value
174145 */
175146 private static String decodeAttributeValue (String value ) {
176147 if (value == null || value .isEmpty ()) {
177148 return value ;
178149 }
179150
180- StringBuilder result = null ; // Lazy allocation
181- int length = value .length ();
182-
183- for (int i = 0 ; i < length ; i ++) {
184- char ch = value .charAt (i );
185- Character replacement = DECODE_TRANS .get (ch );
186-
187- if (replacement != null ) {
188- // First encoded char found - allocate StringBuilder and copy prefix
189- if (result == null ) {
190- result = new StringBuilder (length );
191- result .append (value , 0 , i );
192- }
193- result .append (replacement );
194- } else if (result != null ) {
195- // Already building decoded string
196- result .append (ch );
197- }
198- }
199-
200- return result != null ? result .toString () : value ;
151+ // URLDecoder.decode reverses the encoding applied by URLEncoder.encode
152+ return URLDecoder .decode (value , StandardCharsets .UTF_8 );
201153 }
202154
203155 /**
@@ -345,6 +297,23 @@ private Map<String, Object> convertToCacheHit(JsonNode result) {
345297 */
346298 public String store (String prompt , String response , Map <String , Object > metadata )
347299 throws IOException {
300+ return store (prompt , response , metadata , null );
301+ }
302+
303+ /**
304+ * Store a prompt-response pair in the cache with a per-entry TTL.
305+ *
306+ * <p>Port of redis-vl-python PR #442
307+ *
308+ * @param prompt The user prompt to cache
309+ * @param response The LLM response to cache
310+ * @param metadata Optional metadata (stored as attributes in LangCache)
311+ * @param ttl Time-to-live in seconds (null for default TTL)
312+ * @return The entry ID for the cached entry
313+ * @throws IOException If the API request fails
314+ */
315+ public String store (String prompt , String response , Map <String , Object > metadata , Integer ttl )
316+ throws IOException {
348317 if (prompt == null || prompt .isEmpty ()) {
349318 throw new IllegalArgumentException ("prompt is required" );
350319 }
@@ -359,11 +328,18 @@ public String store(String prompt, String response, Map<String, Object> metadata
359328
360329 if (metadata != null && !metadata .isEmpty ()) {
361330 // Encode all string attribute values so they are accepted by the
362- // LangCache service and remain filterable (PR #437 & #438 )
331+ // LangCache service and remain filterable (PR #442 )
363332 Map <String , Object > safeMetadata = encodeAttributes (metadata );
364333 requestBody .set ("attributes" , objectMapper .valueToTree (safeMetadata ));
365334 }
366335
336+ // Add per-entry TTL if specified (convert seconds to milliseconds)
337+ // Port of redis-vl-python PR #442
338+ if (ttl != null ) {
339+ int ttlMillis = Math .round (ttl * 1000.0f );
340+ requestBody .put ("ttl_millis" , ttlMillis );
341+ }
342+
367343 // Make API request
368344 String storeUrl = serverUrl + "/v1/caches/" + cacheId + "/entries" ;
369345 logger .info (
0 commit comments