solana-agent 31.1.1__py3-none-any.whl → 31.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -80,7 +80,7 @@ class QueryService(QueryServiceInterface):
80
80
  router: Optional[RoutingServiceInterface] = None,
81
81
  output_model: Optional[Type[BaseModel]] = None,
82
82
  capture_schema: Optional[Dict[str, Any]] = None,
83
- capture_name: Optional[str] = None,
83
+ capture_name: Optional[Dict[str, Any]] = None,
84
84
  ) -> AsyncGenerator[Union[str, bytes, BaseModel], None]: # pragma: no cover
85
85
  """Process the user request and generate a response."""
86
86
  try:
@@ -200,7 +200,9 @@ class QueryService(QueryServiceInterface):
200
200
 
201
201
  # 7) Captured data context + incremental save using previous assistant message
202
202
  capture_context = ""
203
- form_complete = False
203
+ # Two completion flags:
204
+ required_complete = False
205
+ form_complete = False # required + optional
204
206
 
205
207
  # Helpers
206
208
  def _non_empty(v: Any) -> bool:
@@ -215,7 +217,6 @@ class QueryService(QueryServiceInterface):
215
217
 
216
218
  def _parse_numbers_list(s: str) -> List[str]:
217
219
  nums = re.findall(r"\b(\d+)\b", s)
218
- # dedupe keep order
219
220
  seen, out = set(), []
220
221
  for n in nums:
221
222
  if n not in seen:
@@ -225,9 +226,7 @@ class QueryService(QueryServiceInterface):
225
226
 
226
227
  def _extract_numbered_options(text: str) -> Dict[str, str]:
227
228
  """Parse previous assistant message for lines like:
228
- '1) Foo', '2. Bar', '- 3) Baz', '* 4. Buzz'
229
- Returns mapping '1' -> 'Foo', etc.
230
- """
229
+ '1) Foo', '1. Foo', '- 1) Foo', '* 1. Foo' -> {'1': 'Foo'}"""
231
230
  options: Dict[str, str] = {}
232
231
  if not text:
233
232
  return options
@@ -235,44 +234,109 @@ class QueryService(QueryServiceInterface):
235
234
  line = raw.strip()
236
235
  if not line:
237
236
  continue
238
- # Common Markdown patterns: "1. Label", "1) Label", "- 1) Label", "* 1. Label"
239
237
  m = re.match(r"^(?:[-*]\s*)?(\d+)[\.)]?\s+(.*)$", line)
240
238
  if m:
241
- idx, label = m.group(1), m.group(2).strip()
242
- # Strip trailing markdown soft-break spaces
243
- label = label.rstrip()
244
- # Ignore labels that are too short or look like continuations
239
+ idx, label = m.group(1), m.group(2).strip().rstrip()
245
240
  if len(label) >= 1:
246
241
  options[idx] = label
247
242
  return options
248
243
 
249
- def _detect_field_from_prev_question(
244
+ # LLM-backed field detection (gpt-4.1-mini) with graceful fallbacks
245
+ class _FieldDetect(BaseModel):
246
+ field: Optional[str] = None
247
+
248
+ async def _detect_field_from_prev_question(
250
249
  prev_text: str, schema: Optional[Dict[str, Any]]
251
250
  ) -> Optional[str]:
252
251
  if not prev_text or not isinstance(schema, dict):
253
252
  return None
254
- t = prev_text.lower()
255
- # Heuristic synonyms for your onboarding schema
256
- patterns = [
257
- ("ideas", ["which ideas attract you", "ideas"]),
258
- ("description", ["please describe yourself", "describe yourself"]),
259
- ("myself", ["tell us about yourself", "about yourself"]),
260
- ("questions", ["do you have any questions"]),
261
- ("rating", ["rating", "1 to 5", "how satisfied", "how happy"]),
262
- ("email", ["email"]),
263
- ("phone", ["phone"]),
264
- ("name", ["name"]),
265
- ("city", ["city"]),
266
- ("state", ["state"]),
267
- ]
268
- candidates = set((schema.get("properties") or {}).keys())
269
- for field, keys in patterns:
270
- if field in candidates and any(key in t for key in keys):
271
- return field
272
- # Fallback: property name appears directly
273
- for field in candidates:
274
- if field in t:
275
- return field
253
+ props = list((schema.get("properties") or {}).keys())
254
+ if not props:
255
+ return None
256
+
257
+ question = prev_text.strip()
258
+ instruction = (
259
+ "You are a strict classifier. Given the assistant's last question and a list of "
260
+ "permitted schema field keys, choose exactly one key that the question is asking the user to answer. "
261
+ "If none apply, return null."
262
+ )
263
+ user_prompt = (
264
+ f"Schema field keys (choose exactly one of these): {props}\n"
265
+ f"Assistant question:\n{question}\n\n"
266
+ 'Return strictly JSON like: {"field": "<one_of_the_keys_or_null>"}'
267
+ )
268
+
269
+ # Try llm_provider.parse_structured_output with mini
270
+ try:
271
+ if hasattr(
272
+ self.agent_service.llm_provider, "parse_structured_output"
273
+ ):
274
+ try:
275
+ result = await self.agent_service.llm_provider.parse_structured_output(
276
+ prompt=user_prompt,
277
+ system_prompt=instruction,
278
+ model_class=_FieldDetect,
279
+ model="gpt-4.1-nano",
280
+ )
281
+ except TypeError:
282
+ # Provider may not accept 'model' kwarg
283
+ result = await self.agent_service.llm_provider.parse_structured_output(
284
+ prompt=user_prompt,
285
+ system_prompt=instruction,
286
+ model_class=_FieldDetect,
287
+ )
288
+ # Read result
289
+ sel = None
290
+ try:
291
+ sel = getattr(result, "field", None)
292
+ except Exception:
293
+ sel = None
294
+ if sel is None:
295
+ try:
296
+ d = result.model_dump()
297
+ sel = d.get("field")
298
+ except Exception:
299
+ sel = None
300
+ if sel in props:
301
+ return sel
302
+ except Exception as e:
303
+ logger.debug(
304
+ f"LLM parse_structured_output field detection failed: {e}"
305
+ )
306
+
307
+ # Fallback: use generate_response with output_model=_FieldDetect
308
+ try:
309
+ async for r in self.agent_service.generate_response(
310
+ agent_name=agent_name,
311
+ user_id=user_id,
312
+ query=user_text,
313
+ images=images,
314
+ memory_context="",
315
+ output_format="text",
316
+ prompt=f"{instruction}\n\n{user_prompt}",
317
+ output_model=_FieldDetect,
318
+ ):
319
+ fd = r
320
+ sel = None
321
+ try:
322
+ sel = fd.field # type: ignore[attr-defined]
323
+ except Exception:
324
+ try:
325
+ d = fd.model_dump()
326
+ sel = d.get("field")
327
+ except Exception:
328
+ sel = None
329
+ if sel in props:
330
+ return sel
331
+ break
332
+ except Exception as e:
333
+ logger.debug(f"LLM generate_response field detection failed: {e}")
334
+
335
+ # Final heuristic fallback (keeps system working if LLM unavailable)
336
+ t = question.lower()
337
+ for key in props:
338
+ if key in t:
339
+ return key
276
340
  return None
277
341
 
278
342
  # Resolve active capture from args or agent config
@@ -322,33 +386,43 @@ class QueryService(QueryServiceInterface):
322
386
  required_fields = list(
323
387
  (active_capture_schema or {}).get("required", []) or []
324
388
  )
325
- # Prefer a field detected from prev assistant; else if exactly one required missing, use it
326
- target_field: Optional[str] = _detect_field_from_prev_question(
327
- prev_assistant, active_capture_schema
328
- )
389
+ all_fields = list(props.keys())
390
+ optional_fields = [
391
+ f for f in all_fields if f not in set(required_fields)
392
+ ]
393
+
329
394
  active_data_existing = (
330
395
  latest_by_name.get(active_capture_name, {}) or {}
331
396
  ).get("data", {}) or {}
332
397
 
333
- def _missing_required() -> List[str]:
398
+ def _missing(fields: List[str]) -> List[str]:
334
399
  return [
335
400
  f
336
- for f in required_fields
401
+ for f in fields
337
402
  if not _non_empty(active_data_existing.get(f))
338
403
  ]
339
404
 
405
+ missing_required = _missing(required_fields)
406
+ missing_optional = _missing(optional_fields)
407
+
408
+ target_field: Optional[
409
+ str
410
+ ] = await _detect_field_from_prev_question(
411
+ prev_assistant, active_capture_schema
412
+ )
340
413
  if not target_field:
341
- missing = _missing_required()
342
- if len(missing) == 1:
343
- target_field = missing[0]
414
+ # If exactly one required missing, target it; else if none required missing and exactly one optional missing, target it.
415
+ if len(missing_required) == 1:
416
+ target_field = missing_required[0]
417
+ elif len(missing_required) == 0 and len(missing_optional) == 1:
418
+ target_field = missing_optional[0]
344
419
 
345
- if target_field:
420
+ if target_field and target_field in props:
346
421
  f_schema = props.get(target_field, {}) or {}
347
422
  f_type = f_schema.get("type")
348
423
  number_to_label = _extract_numbered_options(prev_assistant)
349
424
 
350
425
  if number_to_label:
351
- # Map any numbers in user's reply to their labels
352
426
  nums = _parse_numbers_list(user_text)
353
427
  labels = [
354
428
  number_to_label[n] for n in nums if n in number_to_label
@@ -359,7 +433,6 @@ class QueryService(QueryServiceInterface):
359
433
  else:
360
434
  incremental[target_field] = labels[0]
361
435
 
362
- # If we didn't map via options, fallback to type-based parse
363
436
  if target_field not in incremental:
364
437
  if f_type == "number":
365
438
  m = re.search(r"\b([0-9]+(?:\.[0-9]+)?)\b", user_text)
@@ -369,7 +442,6 @@ class QueryService(QueryServiceInterface):
369
442
  except Exception:
370
443
  pass
371
444
  elif f_type == "array":
372
- # Accept CSV-style input as array of strings
373
445
  parts = [
374
446
  p.strip()
375
447
  for p in re.split(r"[,\n;]+", user_text)
@@ -377,11 +449,10 @@ class QueryService(QueryServiceInterface):
377
449
  ]
378
450
  if parts:
379
451
  incremental[target_field] = parts
380
- else: # string/default
452
+ else:
381
453
  if user_text.strip():
382
454
  incremental[target_field] = user_text.strip()
383
455
 
384
- # Filter out empty junk and save
385
456
  if incremental:
386
457
  cleaned = {
387
458
  k: v for k, v in incremental.items() if _non_empty(v)
@@ -397,6 +468,7 @@ class QueryService(QueryServiceInterface):
397
468
  )
398
469
  except Exception as se:
399
470
  logger.error(f"Error saving incremental capture: {se}")
471
+
400
472
  except Exception as e:
401
473
  logger.debug(f"Incremental extraction skipped: {e}")
402
474
 
@@ -411,19 +483,33 @@ class QueryService(QueryServiceInterface):
411
483
 
412
484
  lines: List[str] = []
413
485
  if active_capture_name and isinstance(active_capture_schema, dict):
414
- active_data = _get_active_data(active_capture_name)
486
+ props = (active_capture_schema or {}).get("properties", {})
415
487
  required_fields = list(
416
488
  (active_capture_schema or {}).get("required", []) or []
417
489
  )
418
- missing = [
419
- f for f in required_fields if not _non_empty(active_data.get(f))
490
+ all_fields = list(props.keys())
491
+ optional_fields = [
492
+ f for f in all_fields if f not in set(required_fields)
420
493
  ]
421
- form_complete = len(missing) == 0 and len(required_fields) > 0
494
+
495
+ active_data = _get_active_data(active_capture_name)
496
+
497
+ def _missing_from(data: Dict[str, Any], fields: List[str]) -> List[str]:
498
+ return [f for f in fields if not _non_empty(data.get(f))]
499
+
500
+ missing_required = _missing_from(active_data, required_fields)
501
+ missing_optional = _missing_from(active_data, optional_fields)
502
+
503
+ required_complete = (
504
+ len(missing_required) == 0 and len(required_fields) > 0
505
+ )
506
+ form_complete = required_complete and len(missing_optional) == 0
422
507
 
423
508
  lines.append(
424
509
  "CAPTURED FORM STATE (Authoritative; do not re-ask filled values):"
425
510
  )
426
511
  lines.append(f"- form_name: {active_capture_name}")
512
+
427
513
  if active_data:
428
514
  pairs = [
429
515
  f"{k}: {v}" for k, v in active_data.items() if _non_empty(v)
@@ -433,8 +519,12 @@ class QueryService(QueryServiceInterface):
433
519
  )
434
520
  else:
435
521
  lines.append("- filled_fields: (none)")
522
+
523
+ lines.append(
524
+ f"- missing_required_fields: {', '.join(missing_required) if missing_required else '(none)'}"
525
+ )
436
526
  lines.append(
437
- f"- missing_required_fields: {', '.join(missing) if missing else '(none)'}"
527
+ f"- missing_optional_fields: {', '.join(missing_optional) if missing_optional else '(none)'}"
438
528
  )
439
529
  lines.append("")
440
530
 
@@ -455,7 +545,7 @@ class QueryService(QueryServiceInterface):
455
545
  if lines:
456
546
  capture_context = "\n".join(lines) + "\n\n"
457
547
 
458
- # Merge contexts
548
+ # Merge contexts + flow rules
459
549
  combined_context = ""
460
550
  if capture_context:
461
551
  combined_context += capture_context
@@ -470,9 +560,11 @@ class QueryService(QueryServiceInterface):
470
560
  "- Prefer KB/tools for facts.\n"
471
561
  "- History is for tone and continuity.\n\n"
472
562
  "FORM FLOW RULES:\n"
473
- "- Ask exactly one missing required field per turn.\n"
563
+ "- Ask exactly one field per turn.\n"
564
+ "- If any required fields are missing, ask the next missing required field.\n"
565
+ "- If all required fields are filled but optional fields are missing, ask the next missing optional field.\n"
474
566
  "- Do NOT re-ask or verify values present in Captured User Data (auto-saved, authoritative).\n"
475
- "- If no required fields are missing, proceed without further capture questions.\n\n"
567
+ "- Do NOT provide summaries until no required or optional fields are missing.\n\n"
476
568
  )
477
569
 
478
570
  # 8) Generate response
@@ -510,7 +602,7 @@ class QueryService(QueryServiceInterface):
510
602
  except Exception:
511
603
  pass
512
604
 
513
- # If form is complete, ask for structured output JSON
605
+ # Only run final structured output when no required or optional fields are missing
514
606
  if capture_schema and capture_name and form_complete:
515
607
  try:
516
608
  DynamicModel = self._build_model_from_json_schema(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: solana-agent
3
- Version: 31.1.1
3
+ Version: 31.1.3
4
4
  Summary: AI Agents for Solana
5
5
  License: MIT
6
6
  Keywords: solana,solana ai,solana agent,ai,ai agent,ai agents
@@ -34,10 +34,10 @@ solana_agent/repositories/memory.py,sha256=F46vZ-Uhj7PX2uFGCRKYsZ8JLmKteMN1d30qG
34
34
  solana_agent/services/__init__.py,sha256=iko0c2MlF8b_SA_nuBGFllr2E3g_JowOrOzGcnU9tkA,162
35
35
  solana_agent/services/agent.py,sha256=dotuINMtW3TQDLq2eNM5r1cAUwhzxbHBotw8p5CLsYU,20983
36
36
  solana_agent/services/knowledge_base.py,sha256=ZvOPrSmcNDgUzz4bJIQ4LeRl9vMZiK9hOfs71IpB7Bk,32735
37
- solana_agent/services/query.py,sha256=FwqZMmuLUI7TmAkzv91D363PfnWajww35s8j5ubatY4,31544
37
+ solana_agent/services/query.py,sha256=ekvuwkhLobg56sIKwHTR13GFL_TV7VkfafA0VmOxkMg,35608
38
38
  solana_agent/services/routing.py,sha256=hsHe8HSGO_xFc0A17WIOGTidLTfLSfApQw3l2HHqkLo,7614
39
- solana_agent-31.1.1.dist-info/LICENSE,sha256=BnSRc-NSFuyF2s496l_4EyrwAP6YimvxWcjPiJ0J7g4,1057
40
- solana_agent-31.1.1.dist-info/METADATA,sha256=Ke1JR65TFAZ3YAE4V_x-WbVXvMfkF-2aj_Hs2xlgcxQ,30013
41
- solana_agent-31.1.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
42
- solana_agent-31.1.1.dist-info/entry_points.txt,sha256=-AuT_mfqk8dlZ0pHuAjx1ouAWpTRjpqvEUa6YV3lmc0,53
43
- solana_agent-31.1.1.dist-info/RECORD,,
39
+ solana_agent-31.1.3.dist-info/LICENSE,sha256=BnSRc-NSFuyF2s496l_4EyrwAP6YimvxWcjPiJ0J7g4,1057
40
+ solana_agent-31.1.3.dist-info/METADATA,sha256=q1PWQGGolVgn2xKH_NtbETngkTybMBS_bp1JQGEUxx4,30013
41
+ solana_agent-31.1.3.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
42
+ solana_agent-31.1.3.dist-info/entry_points.txt,sha256=-AuT_mfqk8dlZ0pHuAjx1ouAWpTRjpqvEUa6YV3lmc0,53
43
+ solana_agent-31.1.3.dist-info/RECORD,,