smolnalysis / app /ckan_agent.py
Meteord's picture
Sync from GitHub via hub-sync
b7097af verified
Raw
History Blame Contribute Delete
31.3 kB
from __future__ import annotations
import json
import re
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
from typing import Any, Literal
try:
from .ckan_support import DEFAULT_CKAN_ENDPOINT, group_list, normalize_ckan_base_url, organization_list, package_search, package_show, tag_search
from .model_roles import ModelResponse, call_role_model
from .openui_support import OpenUIValidationError, _json_arg, parse_openui_lang
except ImportError:
from ckan_support import DEFAULT_CKAN_ENDPOINT, group_list, normalize_ckan_base_url, organization_list, package_search, package_show, tag_search
from model_roles import ModelResponse, call_role_model
from openui_support import OpenUIValidationError, _json_arg, parse_openui_lang
AgentStatus = Literal["selected", "finished", "needs_clarification", "max_tool_calls", "error"]
AgentEventType = Literal["model_action", "tool_call", "tool_result", "selection", "retry", "error", "done"]
ALLOWED_ACTIONS = {"tag_search", "group_list", "organization_list", "package_search", "package_show", "select_resource", "finish", "ask_clarification"}
MAX_TOOL_CALLS = 8
MIN_SELECTION_CONFIDENCE = 0.85
CKAN_ACTION_CONTRACT = (
"You are the smolnalysis CKAN retrieval specialist. Choose the next action to find the correct dataset/resource. "
"Return exactly one JSON object and nothing else. No markdown, no explanation outside JSON. Shape: "
'{"action":"tag_search|group_list|organization_list|package_search|package_show|select_resource|finish|ask_clarification","args":{},"reason":"short reason","confidence":0.0}. '
"Use tag_search first when the user's terms may not match the catalog language. "
"Use group_list or organization_list to discover catalog vocabulary when package_search returns weak or empty results. "
"Use package_show only with observed package_id values. Use select_resource only with observed resource_id values. "
"Only select_resource when the resource directly answers the user request; set confidence >= 0.85 and put a concrete match_evidence string in args. "
"If candidates are broad or only loosely related, continue searching or ask_clarification instead of selecting. "
"Do not invent URLs."
)
OPENUI_CONTRACT = "You translate structured smolnalysis results into OpenUI-Lang. Output OpenUI-Lang only. No markdown, no prose."
@dataclass
class AgentAction:
action: str
args: dict[str, Any] = field(default_factory=dict)
reason: str = ""
confidence: float = 0.0
source: str = "model"
@dataclass
class AgentEvent:
type: AgentEventType
detail: str
data: dict[str, Any] = field(default_factory=dict)
@dataclass
class RetrievalResource:
package_id: str
package_title: str
resource_id: str
name: str
format: str
url: str
@dataclass
class ToolResult:
tool: str
ok: bool
summary: str
data: dict[str, Any] = field(default_factory=dict)
error: str = ""
@dataclass
class AgentSession:
prompt: str
endpoint: str
messages: list[dict[str, str]] = field(default_factory=list)
packages: dict[str, dict[str, Any]] = field(default_factory=dict)
resources: dict[str, RetrievalResource] = field(default_factory=dict)
events: list[AgentEvent] = field(default_factory=list)
tool_calls: int = 0
searched_queries: list[str] = field(default_factory=list)
failed_searches: dict[str, int] = field(default_factory=dict)
searched_tag_queries: list[str] = field(default_factory=list)
consecutive_service_errors: int = 0
discovered_tags: list[str] = field(default_factory=list)
discovered_groups: list[dict[str, Any]] = field(default_factory=list)
discovered_organizations: list[dict[str, Any]] = field(default_factory=list)
catalog_tools_run: set[str] = field(default_factory=set)
@dataclass
class AgentResult:
status: AgentStatus
endpoint: str
prompt: str
messages: list[dict[str, str]]
events: list[AgentEvent]
packages: list[dict[str, Any]]
resources: list[RetrievalResource]
selected_package: dict[str, Any] | None = None
selected_resource: RetrievalResource | None = None
confidence: float = 0.0
clarification: str = ""
def run_ckan_agent(
prompt: str,
endpoint: str | None = None,
history: list[dict[str, str]] | None = None,
on_event: Callable[[AgentEvent], None] | None = None,
model_caller: Callable[[str, list[dict[str, str]], str], ModelResponse] = call_role_model,
max_tool_calls: int = MAX_TOOL_CALLS,
) -> AgentResult:
prompt = _clean_prompt(prompt)
session = AgentSession(
prompt=prompt.strip() or "Find a dataset",
endpoint=_normalize_endpoint_or_default(endpoint),
messages=_initial_messages(prompt, endpoint, history),
)
while session.tool_calls < max_tool_calls:
action = _next_model_action(session, model_caller, on_event)
valid_action, error = validate_action(action, session)
if error:
_record_event(session, AgentEvent("retry", error, {"action": asdict(action)}), on_event)
_append_validation_error(session, action, error)
valid_action = fallback_action(session)
_record_event(session, AgentEvent("model_action", f"{valid_action.action}: {valid_action.reason}", {"action": asdict(valid_action)}), on_event)
if valid_action.action == "ask_clarification":
return _finish(session, "needs_clarification", clarification=str(valid_action.args.get("question") or valid_action.reason))
if valid_action.action == "finish":
return _finish(session, "finished", confidence=valid_action.confidence)
if valid_action.action == "select_resource":
resource_id = str(valid_action.args.get("resource_id", ""))
resource = session.resources.get(resource_id)
package = session.packages.get(resource.package_id) if resource else None
if resource:
_record_event(session, AgentEvent("selection", f"Selected {resource.name} from {resource.package_title}.", {"resource": asdict(resource)}), on_event)
return _finish(session, "selected", selected_package=package, selected_resource=resource, confidence=valid_action.confidence or _score_resource(resource, session.prompt))
_record_event(session, AgentEvent("retry", "Model selected an unobserved resource; falling back.", {"resource_id": resource_id}), on_event)
continue
result = execute_action(valid_action, session, on_event)
session.tool_calls += 1
_append_tool_result(session, valid_action, result)
_record_event(session, AgentEvent("tool_result", result.summary, {"tool_result": asdict(result)}), on_event)
if _should_stop_for_tool_failure(session, result):
_record_event(session, AgentEvent("error", result.summary, {"tool_result": asdict(result)}), on_event)
return _finish(session, "error", clarification=result.summary)
auto_resource = _best_observed_resource(session)
if auto_resource and _score_resource(auto_resource, session.prompt) >= 0.86:
_record_event(session, AgentEvent("selection", f"Selected {auto_resource.name} from {auto_resource.package_title}.", {"resource": asdict(auto_resource)}), on_event)
return _finish(
session,
"selected",
selected_package=session.packages.get(auto_resource.package_id),
selected_resource=auto_resource,
confidence=_score_resource(auto_resource, session.prompt),
)
return _finish(session, "max_tool_calls")
def parse_agent_action(raw: str) -> AgentAction:
text = raw.strip()
try:
payload = json.loads(text)
except json.JSONDecodeError as exc:
payload = _first_json_object(text)
if payload is None:
raise ValueError("Model did not return JSON.") from exc
if not isinstance(payload, dict):
raise ValueError("Model action must be a JSON object.")
return _agent_action_from_payload(payload)
def _agent_action_from_payload(payload: dict[str, Any]) -> AgentAction:
action = str(payload.get("action", "")).strip()
args = payload.get("args") if isinstance(payload.get("args"), dict) else {}
reason = str(payload.get("reason", "")).strip()
try:
confidence = float(payload.get("confidence", 0.0))
except (TypeError, ValueError):
confidence = 0.0
return AgentAction(action, args, reason, max(0.0, min(confidence, 1.0)))
def _first_json_object(text: str) -> dict[str, Any] | None:
decoder = json.JSONDecoder()
for match in re.finditer(r"\{", text):
try:
payload, _index = decoder.raw_decode(text[match.start() :])
except json.JSONDecodeError:
continue
if isinstance(payload, dict):
return payload
return None
def validate_action(action: AgentAction, session: AgentSession) -> tuple[AgentAction, str]:
if action.action not in ALLOWED_ACTIONS:
return action, f"Unknown action '{action.action}'."
if action.action == "package_search":
query = str(action.args.get("query", "")).strip()
if not query or "://" in query:
return action, "package_search requires a plain query string."
if session.failed_searches.get(query, 0) > 0:
return action, f"package_search query already failed in this run: {query}"
rows = int(action.args.get("rows", 5) or 5)
action.args["rows"] = max(1, min(rows, 10))
if action.action == "tag_search":
query = str(action.args.get("query", "")).strip()
if not query or "://" in query:
return action, "tag_search requires a plain query string."
if query in session.searched_tag_queries:
return action, f"tag_search query already ran in this run: {query}"
rows = int(action.args.get("rows", 10) or 10)
action.args["rows"] = max(1, min(rows, 25))
if action.action in {"group_list", "organization_list"}:
if action.action in session.catalog_tools_run:
return action, f"{action.action} already ran in this run; choose tag_search, package_search, package_show, or ask_clarification instead."
rows = int(action.args.get("rows", 10) or 10)
action.args["rows"] = max(1, min(rows, 25))
if action.action == "package_show":
package_id = str(action.args.get("package_id", "")).strip()
if package_id not in session.packages:
return action, f"package_show package_id was not observed: {package_id}"
if session.packages[package_id].get("_shown"):
return action, f"package_show package_id was already inspected: {package_id}"
if action.action == "select_resource":
resource_id = str(action.args.get("resource_id", "")).strip()
if resource_id not in session.resources:
return action, f"select_resource resource_id was not observed: {resource_id}"
evidence = str(action.args.get("match_evidence", "")).strip()
if action.confidence < MIN_SELECTION_CONFIDENCE:
return action, f"select_resource confidence is too low: {action.confidence:.2f}"
if len(evidence) < 12:
return action, "select_resource requires concrete match_evidence in args."
return action, ""
def fallback_action(session: AgentSession) -> AgentAction:
if session.consecutive_service_errors >= 2:
return AgentAction("finish", {}, "CKAN service is currently unavailable; stop instead of repeating failing calls.", 0.0, "fallback")
for package_id, package in session.packages.items():
if package_id and not package.get("_shown"):
return AgentAction("package_show", {"package_id": package_id}, "Inspect the next observed package.", 0.45, "fallback")
best_resource = _best_observed_resource(session)
if best_resource:
return AgentAction(
"select_resource",
{
"resource_id": best_resource.resource_id,
"match_evidence": f"Observed resource '{best_resource.name}' belongs to matching package '{best_resource.package_title}'.",
},
"Select the best observed resource instead of repeating inspection.",
max(MIN_SELECTION_CONFIDENCE, _score_resource(best_resource, session.prompt)),
"fallback",
)
if "tag_search" not in session.catalog_tools_run:
query = _catalog_query(session.prompt)
return AgentAction("tag_search", {"query": query, "rows": 10}, "Discover matching CKAN tags before package search.", 0.45, "fallback")
if "group_list" not in session.catalog_tools_run:
return AgentAction("group_list", {"rows": 15}, "Inspect catalog groups for topic vocabulary.", 0.35, "fallback")
query = _fallback_query(session.prompt, session.searched_queries, session.discovered_tags, session.discovered_groups)
return AgentAction("package_search", {"query": query, "rows": 5}, "Fallback search query.", 0.35, "fallback")
def execute_action(action: AgentAction, session: AgentSession, on_event: Callable[[AgentEvent], None] | None = None) -> ToolResult:
if action.action == "tag_search":
query = str(action.args.get("query", "")).strip()
rows = int(action.args.get("rows", 10) or 10)
session.catalog_tools_run.add("tag_search")
if query not in session.searched_tag_queries:
session.searched_tag_queries.append(query)
_record_event(session, AgentEvent("tool_call", f"tag_search: {query}", {"query": query, "rows": rows}), on_event)
try:
result = tag_search(session.endpoint, query, rows=rows)
except Exception as exc:
return ToolResult("tag_search", False, f"tag_search failed for '{query}': {_short_error(exc)}", error=_short_error(exc))
tags = _names_from_ckan_list_result(result)
for tag in tags:
if tag not in session.discovered_tags:
session.discovered_tags.append(tag)
return ToolResult("tag_search", True, f"Found {len(tags)} tags matching '{query}'.", {"query": query, "tags": tags[:12]})
if action.action == "group_list":
rows = int(action.args.get("rows", 10) or 10)
session.catalog_tools_run.add("group_list")
_record_event(session, AgentEvent("tool_call", f"group_list: {rows}", {"rows": rows}), on_event)
try:
result = group_list(session.endpoint, rows=rows)
except Exception as exc:
return ToolResult("group_list", False, f"group_list failed: {_short_error(exc)}", error=_short_error(exc))
groups = _dicts_from_ckan_list_result(result)
session.discovered_groups = groups
return ToolResult("group_list", True, f"Found {len(groups)} catalog groups.", {"groups": [_compact_named_item(group) for group in groups[:12]]})
if action.action == "organization_list":
rows = int(action.args.get("rows", 10) or 10)
session.catalog_tools_run.add("organization_list")
_record_event(session, AgentEvent("tool_call", f"organization_list: {rows}", {"rows": rows}), on_event)
try:
result = organization_list(session.endpoint, rows=rows)
except Exception as exc:
return ToolResult("organization_list", False, f"organization_list failed: {_short_error(exc)}", error=_short_error(exc))
organizations = _dicts_from_ckan_list_result(result)
session.discovered_organizations = organizations
return ToolResult("organization_list", True, f"Found {len(organizations)} organizations.", {"organizations": [_compact_named_item(org) for org in organizations[:12]]})
if action.action == "package_search":
query = str(action.args.get("query", "")).strip()
rows = int(action.args.get("rows", 5) or 5)
if query not in session.searched_queries:
session.searched_queries.append(query)
_record_event(session, AgentEvent("tool_call", f"package_search: {query}", {"query": query, "rows": rows}), on_event)
try:
result = package_search(session.endpoint, query, rows=rows)
except Exception as exc:
short_error = _short_error(exc)
session.failed_searches[query] = session.failed_searches.get(query, 0) + 1
if _is_service_unavailable_error(short_error):
session.consecutive_service_errors += 1
return ToolResult("package_search", False, f"package_search failed for '{query}': {_short_error(exc)}", error=_short_error(exc))
session.consecutive_service_errors = 0
packages = [package for package in result.get("results", []) if isinstance(package, dict)]
for package in packages:
package_id = _package_id(package)
if package_id:
session.packages[package_id] = package
return ToolResult("package_search", True, f"Found {len(packages)} package candidates for '{query}'.", {"query": query, "packages": [_compact_package(package) for package in packages]})
if action.action == "package_show":
package_id = str(action.args.get("package_id", "")).strip()
_record_event(session, AgentEvent("tool_call", f"package_show: {package_id}", {"package_id": package_id}), on_event)
try:
package = package_show(session.endpoint, package_id)
except Exception as exc:
return ToolResult("package_show", False, f"package_show failed for '{package_id}': {_short_error(exc)}", error=_short_error(exc))
package["_shown"] = True
session.packages[package_id] = package
resources = _resources_from_package(package)
for resource in resources:
session.resources[resource.resource_id] = resource
return ToolResult("package_show", True, f"Inspected {package.get('title') or package_id}: {len(resources)} resources.", {"package": _compact_package(package), "resources": [asdict(resource) for resource in resources]})
return ToolResult(action.action, False, f"Action {action.action} is not executable as a tool.")
def build_retrieval_openui(result: AgentResult) -> str:
event_ids = []
lines = ['root = Card([header, progress, candidates, callout, followups])']
lines.append('header = CardHeader("Dataset search", "CKAN retrieval result")')
for index, event in enumerate(result.events[:12]):
ident = f"event{index + 1}"
event_ids.append(ident)
lines.append(f"{ident} = ListItem({_json_arg(event.type)}, {_json_arg(event.detail)})")
if not event_ids:
lines.append('event1 = ListItem("start", "No retrieval steps were recorded.")')
event_ids.append("event1")
lines.append(f"progress = ListBlock([{', '.join(event_ids)}], \"number\")")
rows = [_compact_package(package) for package in result.packages[:8]]
if not rows:
rows = [{"title": "No candidates", "name": result.prompt, "resources": 0}]
columns = list(rows[0].keys())[:4]
for index, column in enumerate(columns):
lines.append(f"col{index + 1} = Col({_json_arg(column)}, {_json_arg([row.get(column) for row in rows])}, \"string\")")
lines.append(f"candidates = Table([{', '.join(f'col{index + 1}' for index in range(len(columns)))}])")
if result.selected_resource:
message = f"Selected {result.selected_resource.name} from {result.selected_resource.package_title}."
elif result.clarification:
message = result.clarification
else:
message = "No final resource selected yet."
lines.append(f'callout = Callout("info", "Result", {_json_arg(message)})')
lines.extend(
[
'f1 = FollowUpItem("Try another CKAN search")',
'f2 = FollowUpItem("Inspect the selected dataset")',
'followups = FollowUpBlock([f1, f2])',
]
)
return "\n".join(lines)
def generate_openui_for_result(
result: AgentResult,
model_caller: Callable[[str, list[dict[str, str]], str], ModelResponse] = call_role_model,
) -> str:
payload = {
"status": result.status,
"selected_package": result.selected_package,
"selected_resource": asdict(result.selected_resource) if result.selected_resource else None,
"events": [asdict(event) for event in result.events],
}
messages = [{"role": "user", "content": json.dumps(payload, ensure_ascii=False, default=str)}]
first_content = ""
try:
response = model_caller("openui_translator", messages, OPENUI_CONTRACT)
first_content = response.content
parse_openui_lang(response.content)
return response.content
except Exception:
try:
repair_messages = [*messages, {"role": "assistant", "content": first_content}, {"role": "user", "content": "Repair this as valid OpenUI-Lang only."}]
repaired = model_caller("openui_translator", repair_messages, OPENUI_CONTRACT)
parse_openui_lang(repaired.content)
return repaired.content
except Exception:
fallback = build_retrieval_openui(result)
parse_openui_lang(fallback)
return fallback
def _next_model_action(
session: AgentSession,
model_caller: Callable[[str, list[dict[str, str]], str], ModelResponse],
on_event: Callable[[AgentEvent], None] | None,
) -> AgentAction:
try:
response = model_caller("ckan_retrieval", session.messages, CKAN_ACTION_CONTRACT)
action = parse_agent_action(response.content)
session.messages.append({"role": "assistant", "content": json.dumps(asdict(action), ensure_ascii=False)})
return action
except Exception as exc:
_record_event(session, AgentEvent("retry", f"Model action failed: {_short_error(exc)}"), on_event)
return fallback_action(session)
def _initial_messages(prompt: str, endpoint: str | None, history: list[dict[str, str]] | None) -> list[dict[str, str]]:
messages = []
messages.append(
{
"role": "user",
"content": f"User request: {prompt.strip() or 'Find a dataset'}\nCKAN endpoint: {_normalize_endpoint_or_default(endpoint)}",
}
)
return messages
def _append_tool_result(session: AgentSession, action: AgentAction, result: ToolResult) -> None:
session.messages.append(
{
"role": "user",
"content": "Tool observation:\n" + json.dumps(
{
"action": asdict(action),
"result": asdict(result),
"catalog_tools_already_run": sorted(session.catalog_tools_run),
"instruction": "Do not repeat catalog discovery tools that already ran. Choose the next useful tool.",
},
ensure_ascii=False,
default=str,
),
}
)
def _append_validation_error(session: AgentSession, action: AgentAction, error: str) -> None:
session.messages.append(
{
"role": "user",
"content": "Rejected model action:\n" + json.dumps(
{
"action": asdict(action),
"error": error,
"catalog_tools_already_run": sorted(session.catalog_tools_run),
"instruction": "Choose a different valid action. Do not repeat rejected actions.",
},
ensure_ascii=False,
default=str,
),
}
)
def _record_event(session: AgentSession, event: AgentEvent, on_event: Callable[[AgentEvent], None] | None = None) -> None:
session.events.append(event)
if on_event:
on_event(event)
def _finish(
session: AgentSession,
status: AgentStatus,
selected_package: dict[str, Any] | None = None,
selected_resource: RetrievalResource | None = None,
confidence: float = 0.0,
clarification: str = "",
) -> AgentResult:
result = AgentResult(
status=status,
endpoint=session.endpoint,
prompt=session.prompt,
messages=session.messages,
events=session.events,
packages=list(session.packages.values()),
resources=list(session.resources.values()),
selected_package=selected_package,
selected_resource=selected_resource,
confidence=confidence,
clarification=clarification,
)
session.events.append(AgentEvent("done", f"Agent stopped with status {status}.", {"status": status}))
return result
def _resources_from_package(package: dict[str, Any]) -> list[RetrievalResource]:
package_id = _package_id(package)
package_title = str(package.get("title") or package.get("name") or package_id or "CKAN package")
resources: list[RetrievalResource] = []
for resource in package.get("resources") or []:
if not isinstance(resource, dict):
continue
resource_id = str(resource.get("id") or "")
if not resource_id:
continue
resources.append(
RetrievalResource(
package_id=package_id,
package_title=package_title,
resource_id=resource_id,
name=str(resource.get("name") or resource.get("description") or resource.get("url") or "resource"),
format=str(resource.get("format") or resource.get("mimetype") or "").lower(),
url=str(resource.get("url") or ""),
)
)
return resources
def _best_observed_resource(session: AgentSession) -> RetrievalResource | None:
best: RetrievalResource | None = None
best_score = 0.0
for resource in session.resources.values():
score = _score_resource(resource, session.prompt)
if score > best_score:
best = resource
best_score = score
return best
def _score_resource(resource: RetrievalResource, prompt: str) -> float:
haystack = f"{resource.package_title} {resource.name} {resource.format} {resource.url}".casefold()
terms = [term for term in re.findall(r"[\w-]+", prompt.casefold()) if len(term) > 2]
score = 0.15
if "csv" in resource.format or resource.url.casefold().endswith(".csv"):
score += 0.45
if any(term in haystack for term in terms):
score += 0.25
if any(term in haystack for term in ("fahrrad", "rad", "raddauer", "radverkehr")) and any(term in prompt.casefold() for term in ("fahrrad", "fahrräder", "rad", "bike", "bicycle")):
score += 0.25
if "metadata" in haystack or "metadaten" in haystack:
score -= 0.2
return max(0.0, min(score, 1.0))
def _compact_package(package: dict[str, Any]) -> dict[str, Any]:
return {
"package_id": _package_id(package),
"name": str(package.get("name") or ""),
"title": str(package.get("title") or package.get("name") or ""),
"resources": len(package.get("resources") or []),
}
def _package_id(package: dict[str, Any]) -> str:
return str(package.get("id") or package.get("name") or "")
def _fallback_query(
prompt: str,
searched_queries: list[str] | int | None = None,
discovered_tags: list[str] | None = None,
discovered_groups: list[dict[str, Any]] | None = None,
) -> str:
if isinstance(searched_queries, int):
searched = []
index = searched_queries
else:
searched = list(searched_queries or [])
index = len(searched)
base = _catalog_query(prompt)
discovered = _matching_discovered_queries(discovered_tags or [], discovered_groups or [])
variants = _unique_queries([*discovered, base, f"{base} csv"])
for query in variants:
if query not in searched:
return query
return variants[min(index, len(variants) - 1)]
def _matching_discovered_queries(tags: list[str], groups: list[dict[str, Any]]) -> list[str]:
candidates: list[str] = []
for tag in tags:
clean = str(tag).strip()
if clean:
candidates.append(clean)
for group in groups:
label = str(group.get("title") or group.get("display_name") or group.get("name") or "").strip()
if label:
candidates.append(label)
return _unique_queries(candidates)
def _catalog_query(prompt: str) -> str:
return _clean_prompt(prompt) or "open data"
def _search_terms(prompt: str) -> list[str]:
prompt = _clean_prompt(prompt)
terms = []
for term in re.findall(r"[\w-]+", prompt.casefold()):
clean = term.strip("-_")
if len(clean) <= 2 or clean.isdigit():
continue
if clean not in terms:
terms.append(clean)
return terms
def _unique_queries(queries: list[str]) -> list[str]:
unique = []
for query in queries:
clean = " ".join(query.split()).strip()
if clean and clean not in unique:
unique.append(clean)
return unique or ["open data"]
def _clean_prompt(prompt: str) -> str:
value = str(prompt or "").strip()
content_match = re.search(r"<content>(.*?)</content>", value, re.IGNORECASE | re.DOTALL)
if content_match:
value = content_match.group(1).strip()
value = re.sub(r"<context>.*?</context>", " ", value, flags=re.IGNORECASE | re.DOTALL)
value = re.sub(r"<[^>]+>", " ", value)
value = re.sub(r"\s+", " ", value).strip()
return value
def _names_from_ckan_list_result(result: dict[str, Any]) -> list[str]:
values = result.get("results") or result.get("value") or []
if not isinstance(values, list):
return []
names: list[str] = []
for item in values:
if isinstance(item, dict):
name = str(item.get("name") or item.get("display_name") or "").strip()
else:
name = str(item).strip()
if name and name not in names:
names.append(name)
return names
def _dicts_from_ckan_list_result(result: dict[str, Any]) -> list[dict[str, Any]]:
values = result.get("results") or result.get("value") or []
if not isinstance(values, list):
return []
return [item for item in values if isinstance(item, dict)]
def _compact_named_item(item: dict[str, Any]) -> dict[str, str]:
return {
"name": str(item.get("name") or ""),
"title": str(item.get("title") or item.get("display_name") or item.get("name") or ""),
}
def _normalize_endpoint_or_default(endpoint: str | None) -> str:
if not endpoint:
return DEFAULT_CKAN_ENDPOINT
try:
return normalize_ckan_base_url(endpoint)
except ValueError:
return DEFAULT_CKAN_ENDPOINT
def _short_error(exc: Exception) -> str:
return f"{type(exc).__name__}: {str(exc).strip() or type(exc).__name__}"
def _is_service_unavailable_error(error: str) -> bool:
return "503" in error or "service unavailable" in error.casefold()
def _should_stop_for_tool_failure(session: AgentSession, result: ToolResult) -> bool:
if result.ok:
return False
if session.consecutive_service_errors >= 2:
return True
return any(count >= 2 for count in session.failed_searches.values())