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.
- solana_agent/services/query.py +150 -58
- {solana_agent-31.1.1.dist-info → solana_agent-31.1.3.dist-info}/METADATA +1 -1
- {solana_agent-31.1.1.dist-info → solana_agent-31.1.3.dist-info}/RECORD +6 -6
- {solana_agent-31.1.1.dist-info → solana_agent-31.1.3.dist-info}/LICENSE +0 -0
- {solana_agent-31.1.1.dist-info → solana_agent-31.1.3.dist-info}/WHEEL +0 -0
- {solana_agent-31.1.1.dist-info → solana_agent-31.1.3.dist-info}/entry_points.txt +0 -0
solana_agent/services/query.py
CHANGED
@@ -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
|
-
|
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', '
|
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
|
-
|
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
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
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
|
-
|
326
|
-
|
327
|
-
|
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
|
398
|
+
def _missing(fields: List[str]) -> List[str]:
|
334
399
|
return [
|
335
400
|
f
|
336
|
-
for f in
|
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
|
342
|
-
if len(
|
343
|
-
target_field =
|
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:
|
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
|
-
|
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
|
-
|
419
|
-
|
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
|
-
|
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"-
|
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
|
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
|
-
"-
|
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
|
-
#
|
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(
|
@@ -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=
|
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.
|
40
|
-
solana_agent-31.1.
|
41
|
-
solana_agent-31.1.
|
42
|
-
solana_agent-31.1.
|
43
|
-
solana_agent-31.1.
|
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,,
|
File without changes
|
File without changes
|
File without changes
|