2025-05-21 14:24:32 -07:00
|
|
|
|
"""
|
|
|
|
|
|
title: Qortal API Toolkit
|
|
|
|
|
|
author: crowetic (with assistance from chatgpt)
|
|
|
|
|
|
git_url: https://gitea.qortal.link/crowetic/AI-Dev
|
|
|
|
|
|
version: 0.3.0
|
|
|
|
|
|
license: MIT
|
|
|
|
|
|
description: Query any Qortal Core HTTP endpoint directly from Open WebUI.
|
|
|
|
|
|
|
|
|
|
|
|
HOW-TO
|
|
|
|
|
|
------
|
2025-05-22 16:47:47 -07:00
|
|
|
|
1. Add this in 'functions' to create the tool function
|
2025-05-21 14:24:32 -07:00
|
|
|
|
2. Add the tool to your model
|
|
|
|
|
|
3. In any model/preset: *Chat → ⚙️ Tools* → enable **Qortal API Toolkit**.
|
|
|
|
|
|
4. Ask something like:
|
|
|
|
|
|
“Get the balance of address Q...”
|
2025-05-22 16:47:47 -07:00
|
|
|
|
The LLM will auto-call `qortal_orchestrate`.
|
2025-05-21 14:24:32 -07:00
|
|
|
|
|
|
|
|
|
|
________ __ .__ _____ __________.___ _____ __ .__
|
|
|
|
|
|
\_____ \ ____________/ |______ | | / _ \\______ \ |/ ____\_ __ ____ _____/ |_|__| ____ ____
|
|
|
|
|
|
/ / \ \ / _ \_ __ \ __\__ \ | | / /_\ \| ___/ \ __\ | \/ \_/ ___\ __\ |/ _ \ / \
|
|
|
|
|
|
/ \_/. ( <_> ) | \/| | / __ \| |_/ | \ | | || | | | / | \ \___| | | ( <_> ) | \
|
|
|
|
|
|
\_____\ \_/\____/|__| |__| (____ /____|____|__ /____| |___||__| |____/|___| /\___ >__| |__|\____/|___| /
|
|
|
|
|
|
\__> \/ \/ \/ \/ \/
|
|
|
|
|
|
|
|
|
|
|
|
___. __ .__
|
|
|
|
|
|
\_ |__ ___.__. /\ ___________ ______ _ __ _____/ |_|__| ____
|
|
|
|
|
|
| __ < | | \/ _/ ___\_ __ \/ _ \ \/ \/ // __ \ __\ |/ ___\
|
|
|
|
|
|
| \_\ \___ | /\ \ \___| | \( <_> ) /\ ___/| | | \ \___
|
|
|
|
|
|
|___ / ____| \/ \___ >__| \____/ \/\_/ \___ >__| |__|\___ >
|
|
|
|
|
|
\/\/ \/ \/ \/
|
2025-05-22 16:47:47 -07:00
|
|
|
|
⭐ Quick examples ⭐
|
|
|
|
|
|
• Get block height
|
|
|
|
|
|
{"endpoint":"/blocks/height"}
|
|
|
|
|
|
|
|
|
|
|
|
• Get balance
|
|
|
|
|
|
{"endpoint":"/addresses/balance/{address}",
|
|
|
|
|
|
"path_params_json":"{\"address\":\"Q...\"}"}
|
|
|
|
|
|
|
|
|
|
|
|
• Publish (POST)
|
|
|
|
|
|
{"endpoint":"/arbitrary/publish","method":"POST",
|
|
|
|
|
|
"json_body_json":"{\"name\":\"example\",\"service\":\"SCRIPT\"}"}
|
|
|
|
|
|
|
2025-05-21 14:24:32 -07:00
|
|
|
|
"""
|
|
|
|
|
|
|
2025-05-22 16:47:47 -07:00
|
|
|
|
import os, json, re, requests, difflib, asyncio
|
2025-05-21 14:24:32 -07:00
|
|
|
|
from typing import Optional
|
|
|
|
|
|
from pydantic import BaseModel, Field, ConfigDict
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-05-22 16:47:47 -07:00
|
|
|
|
# ───────────────────────────────────────────────────────── helpers ─────────
|
2025-05-21 14:24:32 -07:00
|
|
|
|
class _EventEmitter:
|
2025-05-22 16:47:47 -07:00
|
|
|
|
def __init__(self, cb):
|
2025-05-21 14:24:32 -07:00
|
|
|
|
self.cb = cb
|
|
|
|
|
|
|
|
|
|
|
|
async def emit(self, msg, status="in_progress", done=False):
|
|
|
|
|
|
if self.cb:
|
|
|
|
|
|
await self.cb(
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "status",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
"description": msg,
|
|
|
|
|
|
"done": done,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-05-22 16:47:47 -07:00
|
|
|
|
# ───────────────────────────────────────────────────────── TOOL ────────────
|
2025-05-21 14:24:32 -07:00
|
|
|
|
class Tools:
|
2025-05-22 16:47:47 -07:00
|
|
|
|
# ───────── user-visible knobs ─────────
|
2025-05-21 14:24:32 -07:00
|
|
|
|
class Valves(BaseModel):
|
|
|
|
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
|
|
|
|
|
|
|
|
QORTAL_URL: str = Field(
|
|
|
|
|
|
default=os.getenv("QORTAL_API_URL", "https://api.qortal.org"),
|
|
|
|
|
|
description="Base URL of your Qortal node",
|
|
|
|
|
|
)
|
2025-05-22 16:47:47 -07:00
|
|
|
|
OPENAPI_URL: str = Field(
|
|
|
|
|
|
default=os.getenv("QORTAL_OPENAPI_URL", ""),
|
|
|
|
|
|
description="Custom openapi.json URL (leave blank for default)",
|
|
|
|
|
|
)
|
|
|
|
|
|
HTTP_TIMEOUT: int = Field(default=30, description="Request timeout (s)")
|
2025-05-21 14:24:32 -07:00
|
|
|
|
ALLOW_POST: bool = Field(
|
|
|
|
|
|
default=False,
|
|
|
|
|
|
description="Enable POST endpoints (node must allow them)",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-05-22 16:47:47 -07:00
|
|
|
|
# ───────── initialiser ─────────
|
2025-05-21 14:24:32 -07:00
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.valves = self.Valves()
|
|
|
|
|
|
self.headers = {"User-Agent": "Open-WebUI Qortal Toolkit (+https://qortal.org)"}
|
2025-05-22 16:47:47 -07:00
|
|
|
|
self._spec_cache = None # cache for openapi.json
|
|
|
|
|
|
|
|
|
|
|
|
# ───────── OpenAPI loader ─────────
|
|
|
|
|
|
async def _load_spec(self):
|
|
|
|
|
|
if self._spec_cache is not None:
|
|
|
|
|
|
return self._spec_cache
|
|
|
|
|
|
|
|
|
|
|
|
url = (
|
|
|
|
|
|
self.valves.OPENAPI_URL
|
|
|
|
|
|
or f"{self.valves.QORTAL_URL.rstrip('/')}/openapi.json"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def fetch():
|
|
|
|
|
|
r = requests.get(url, headers=self.headers, timeout=self.valves.HTTP_TIMEOUT)
|
|
|
|
|
|
r.raise_for_status()
|
|
|
|
|
|
return r.json()
|
|
|
|
|
|
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._spec_cache = await loop.run_in_executor(None, fetch)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
self._spec_cache = {}
|
|
|
|
|
|
return self._spec_cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ───────── discovery helpers ─────────
|
|
|
|
|
|
async def qortal_openapi(self) -> str:
|
|
|
|
|
|
"""Return raw openapi.json."""
|
|
|
|
|
|
return json.dumps(await self._load_spec())
|
|
|
|
|
|
|
|
|
|
|
|
async def qortal_list_paths(self) -> str:
|
|
|
|
|
|
"""List every path in the spec."""
|
|
|
|
|
|
return json.dumps(list((await self._load_spec()).get("paths", {}).keys()))
|
|
|
|
|
|
|
|
|
|
|
|
async def qortal_describe(self, path: str) -> str:
|
|
|
|
|
|
"""Return the schema entry for a single path."""
|
|
|
|
|
|
return json.dumps(
|
|
|
|
|
|
(await self._load_spec()).get("paths", {}).get(path)
|
|
|
|
|
|
or {"error": "unknown_path"}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def qortal_build_call(
|
|
|
|
|
|
self, path: str, example_values_json: str = "{}"
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Return a skeleton argument dict for qortal_call.
|
|
|
|
|
|
Utilize this to figure out the details of an endpoint prior to making the call.
|
|
|
|
|
|
"""
|
|
|
|
|
|
spec = await self._load_spec()
|
|
|
|
|
|
if path not in spec.get("paths", {}):
|
|
|
|
|
|
return json.dumps({"error": "unknown_path"})
|
|
|
|
|
|
|
|
|
|
|
|
example_vals = json.loads(example_values_json or "{}")
|
|
|
|
|
|
tokens = [seg[1:-1] for seg in path.split("/") if seg.startswith("{")]
|
|
|
|
|
|
missing = [t for t in tokens if t not in example_vals]
|
|
|
|
|
|
|
|
|
|
|
|
return json.dumps(
|
|
|
|
|
|
{
|
|
|
|
|
|
"endpoint": path,
|
|
|
|
|
|
"method": "GET",
|
|
|
|
|
|
"path_params_json": json.dumps(example_vals or {}),
|
|
|
|
|
|
"query_params_json": "{}",
|
|
|
|
|
|
"json_body_json": "{}",
|
|
|
|
|
|
"needs": missing,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2025-05-21 14:24:32 -07:00
|
|
|
|
|
|
|
|
|
|
async def qortal_call(
|
|
|
|
|
|
self,
|
|
|
|
|
|
endpoint: str,
|
|
|
|
|
|
method: str = "GET",
|
|
|
|
|
|
path_params_json: str = "{}",
|
|
|
|
|
|
query_params_json: str = "{}",
|
|
|
|
|
|
json_body_json: str = "{}",
|
|
|
|
|
|
__event_emitter__=None,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Generic Qortal HTTP call.
|
|
|
|
|
|
|
2025-05-22 16:47:47 -07:00
|
|
|
|
🟢 Correct template
|
|
|
|
|
|
{
|
|
|
|
|
|
"endpoint": "/names/{name}",
|
|
|
|
|
|
"method": "GET", <-- string, default "GET"
|
|
|
|
|
|
"path_params_json": "{\"name\":\"crowetic\"}",
|
|
|
|
|
|
"query_params_json": "{}",
|
|
|
|
|
|
"json_body_json": "{}"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
🔴 Common mistakes (DON’T DO THESE)
|
|
|
|
|
|
{
|
|
|
|
|
|
"endpoint": "GET", <-- wrong field, value swapped
|
|
|
|
|
|
"path": "/names/{name}" <-- 'path' is not a parameter
|
|
|
|
|
|
}
|
2025-05-21 14:24:32 -07:00
|
|
|
|
"""
|
2025-05-22 16:47:47 -07:00
|
|
|
|
path_params_json = path_params_json or "{}"
|
|
|
|
|
|
query_params_json = query_params_json or "{}"
|
|
|
|
|
|
json_body_json = json_body_json or "{}"
|
|
|
|
|
|
|
|
|
|
|
|
# swap-correction guard
|
|
|
|
|
|
if endpoint.upper() in ("GET", "POST") and method.startswith("/"):
|
|
|
|
|
|
endpoint, method = method, endpoint # silently swap them
|
|
|
|
|
|
|
2025-05-21 14:24:32 -07:00
|
|
|
|
emitter = _EventEmitter(__event_emitter__)
|
|
|
|
|
|
await emitter.emit(f"{method.upper()} {endpoint}")
|
|
|
|
|
|
|
2025-05-22 16:47:47 -07:00
|
|
|
|
# parse JSON args
|
2025-05-21 14:24:32 -07:00
|
|
|
|
try:
|
2025-05-22 16:47:47 -07:00
|
|
|
|
path_params = json.loads(path_params_json)
|
|
|
|
|
|
query_params = json.loads(query_params_json)
|
|
|
|
|
|
json_body = json.loads(json_body_json)
|
2025-05-21 14:24:32 -07:00
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
|
await emitter.emit("Bad JSON in arguments", "error", True)
|
|
|
|
|
|
return json.dumps({"success": False, "error": str(e)})
|
|
|
|
|
|
|
2025-05-22 16:47:47 -07:00
|
|
|
|
# check missing tokens
|
|
|
|
|
|
unfilled = [
|
|
|
|
|
|
tok for tok in re.findall(r"{(.*?)}", endpoint) if tok not in path_params
|
|
|
|
|
|
]
|
|
|
|
|
|
if unfilled:
|
|
|
|
|
|
await emitter.emit("Missing path parameters", "error", True)
|
|
|
|
|
|
return json.dumps(
|
|
|
|
|
|
{"success": False, "error": "missing_path_params", "needs": unfilled}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# build URL
|
2025-05-21 14:24:32 -07:00
|
|
|
|
url = self.valves.QORTAL_URL.rstrip("/") + "/" + endpoint.lstrip("/")
|
|
|
|
|
|
for k, v in path_params.items():
|
|
|
|
|
|
url = url.replace("{" + k + "}", str(v))
|
|
|
|
|
|
|
|
|
|
|
|
if method.upper() == "POST" and not self.valves.ALLOW_POST:
|
|
|
|
|
|
await emitter.emit("POST disabled", "error", True)
|
|
|
|
|
|
return json.dumps({"success": False, "error": "post_disabled"})
|
|
|
|
|
|
|
2025-05-22 16:47:47 -07:00
|
|
|
|
# perform request
|
|
|
|
|
|
def do_request():
|
2025-05-21 14:24:32 -07:00
|
|
|
|
r = requests.request(
|
|
|
|
|
|
method.upper(),
|
|
|
|
|
|
url,
|
|
|
|
|
|
params=query_params,
|
|
|
|
|
|
json=json_body if method.upper() == "POST" else None,
|
|
|
|
|
|
headers=self.headers,
|
|
|
|
|
|
timeout=self.valves.HTTP_TIMEOUT,
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = r.json()
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
data = r.text
|
2025-05-22 16:47:47 -07:00
|
|
|
|
return {
|
|
|
|
|
|
"ok": r.ok,
|
|
|
|
|
|
"status": r.status_code,
|
|
|
|
|
|
"url": r.url,
|
|
|
|
|
|
"data": data,
|
|
|
|
|
|
}
|
2025-05-21 14:24:32 -07:00
|
|
|
|
|
2025-05-22 16:47:47 -07:00
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = await loop.run_in_executor(None, do_request)
|
|
|
|
|
|
await _EventEmitter(__event_emitter__).emit(f"→ {resp['status']}", "complete", True)
|
|
|
|
|
|
return json.dumps({
|
|
|
|
|
|
"success": resp["ok"],
|
|
|
|
|
|
"status_code": resp["status"],
|
|
|
|
|
|
"url": resp["url"],
|
|
|
|
|
|
"data": resp["data"],
|
|
|
|
|
|
}, ensure_ascii=False)
|
2025-05-21 14:24:32 -07:00
|
|
|
|
except Exception as e:
|
2025-05-22 16:47:47 -07:00
|
|
|
|
await _EventEmitter(__event_emitter__).emit(str(e), "error", True)
|
2025-05-21 14:24:32 -07:00
|
|
|
|
return json.dumps({"success": False, "error": str(e)})
|
2025-05-22 16:47:47 -07:00
|
|
|
|
|
|
|
|
|
|
def extract_key_points(self, user_query: str) -> dict:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Heuristic parser that returns a dict:
|
|
|
|
|
|
- action: e.g. "get balance", "query block height"
|
|
|
|
|
|
- addresses: list of detected Qortal addresses
|
|
|
|
|
|
- params: other parsed params like height or name
|
|
|
|
|
|
"""
|
|
|
|
|
|
q = user_query.strip()
|
|
|
|
|
|
|
|
|
|
|
|
# 1) Detect all Qortal addresses (Q + 27–34 alphanum chars)
|
|
|
|
|
|
addresses = re.findall(r"\bQ[a-zA-Z0-9]{26,33}\b", q)
|
|
|
|
|
|
|
|
|
|
|
|
# 2) Detect numeric params (height, limit, etc.)
|
|
|
|
|
|
params = {}
|
|
|
|
|
|
m = re.search(r"\bheight\s*(?:is|=|:)?\s*(\d+)\b", q, flags=re.IGNORECASE)
|
|
|
|
|
|
if m:
|
|
|
|
|
|
params["height"] = int(m.group(1))
|
|
|
|
|
|
|
|
|
|
|
|
m = re.search(r"\bname\s*(?:is|=|:)?\s*([A-Za-z0-9_]+)\b", q, flags=re.IGNORECASE)
|
|
|
|
|
|
if m:
|
|
|
|
|
|
params["name"] = m.group(1)
|
|
|
|
|
|
|
|
|
|
|
|
m = re.search(r"\blimit\s*(?:is|=|:)?\s*(\d+)\b", q, flags=re.IGNORECASE)
|
|
|
|
|
|
if m:
|
|
|
|
|
|
params["limit"] = int(m.group(1))
|
|
|
|
|
|
|
|
|
|
|
|
# 3) Fallback: any other key: value pairs “key: value”
|
|
|
|
|
|
for key, val in re.findall(r"(\w+)\s*:\s*([^\s,]+)", q):
|
|
|
|
|
|
if key.lower() not in params:
|
|
|
|
|
|
# try casting to int
|
|
|
|
|
|
if val.isdigit():
|
|
|
|
|
|
params[key.lower()] = int(val)
|
|
|
|
|
|
else:
|
|
|
|
|
|
params[key.lower()] = val
|
|
|
|
|
|
|
|
|
|
|
|
# 4) Extract an “action” phrase:
|
|
|
|
|
|
# verb (get|query|fetch) + everything up until keyword “of”/address/param
|
|
|
|
|
|
action = ""
|
|
|
|
|
|
m = re.search(
|
|
|
|
|
|
r"(?i)\b(get|query|fetch)\s+(.+?)(?=\s+(of\b|\bQ[a-zA-Z0-9]{26,33}\b|\bheight\b|\bname\b|$))",
|
|
|
|
|
|
q,
|
|
|
|
|
|
)
|
|
|
|
|
|
if m:
|
|
|
|
|
|
action = f"{m.group(1).lower()} {m.group(2).strip().lower()}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
# fallback: first 4 words
|
|
|
|
|
|
action = " ".join(q.split()[:4]).lower()
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"action": action,
|
|
|
|
|
|
"addresses": addresses,
|
|
|
|
|
|
"params": params,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def find_candidate_endpoints(self, key_points: dict, limit: int) -> list[str]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Fuzzy-match key words (e.g. 'balance', 'height') against each path summary.
|
|
|
|
|
|
"""
|
|
|
|
|
|
spec = self._spec_cache or {}
|
|
|
|
|
|
paths = spec.get("paths", {})
|
|
|
|
|
|
summaries = {
|
|
|
|
|
|
p: (ops.get("get", {}).get("summary", "") + " " +
|
|
|
|
|
|
ops.get("post", {}).get("summary", ""))
|
|
|
|
|
|
for p, ops in paths.items()
|
|
|
|
|
|
}
|
|
|
|
|
|
# pick the top `limit` matches against the user query keywords
|
|
|
|
|
|
pool = list(summaries.keys())
|
|
|
|
|
|
# flatten key words
|
|
|
|
|
|
keywords = []
|
|
|
|
|
|
if key_points["action"]:
|
|
|
|
|
|
keywords += key_points["action"].split() # split action into words
|
|
|
|
|
|
keywords += key_points["addresses"] # each address
|
|
|
|
|
|
for k, v in key_points["params"].items():
|
|
|
|
|
|
keywords.append(k) # param name
|
|
|
|
|
|
keywords.append(str(v)) # param value
|
|
|
|
|
|
|
|
|
|
|
|
scores = {}
|
|
|
|
|
|
for p in pool:
|
|
|
|
|
|
text = summaries[p].lower() + " " + p.lower()
|
|
|
|
|
|
scores[p] = max(difflib.SequenceMatcher(None, text, kw.lower()).ratio()
|
|
|
|
|
|
for kw in keywords) if keywords else 0
|
|
|
|
|
|
ranked = sorted(scores, key=lambda p: scores[p], reverse=True)
|
|
|
|
|
|
return ranked[:limit]
|
|
|
|
|
|
|
|
|
|
|
|
async def get_endpoint_schema(self, path: str) -> dict:
|
|
|
|
|
|
spec = await self._load_spec()
|
|
|
|
|
|
return spec.get("paths", {}).get(path, {})
|
|
|
|
|
|
|
|
|
|
|
|
def build_call_args(
|
|
|
|
|
|
self,
|
|
|
|
|
|
path: str,
|
|
|
|
|
|
schema: dict,
|
|
|
|
|
|
key_points: dict,
|
|
|
|
|
|
attempt: int
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Fill in path/query/body based on key_points.
|
|
|
|
|
|
You could randomize or tweak defaults per attempt.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# get required path tokens
|
|
|
|
|
|
tokens = [seg[1:-1] for seg in path.split("/") if seg.startswith("{")]
|
|
|
|
|
|
# naive: take first address or number for each
|
|
|
|
|
|
path_vals = {}
|
|
|
|
|
|
for token in tokens:
|
|
|
|
|
|
if token == "address" and key_points["addresses"]:
|
|
|
|
|
|
path_vals[token] = key_points["addresses"][0]
|
|
|
|
|
|
elif token in key_points["params"]:
|
|
|
|
|
|
path_vals[token] = key_points["params"][token]
|
|
|
|
|
|
return {
|
|
|
|
|
|
"endpoint": path,
|
|
|
|
|
|
"method": "GET" if "get" in schema else "POST",
|
|
|
|
|
|
"path_params_json": json.dumps(path_vals),
|
|
|
|
|
|
"query_params_json": "{}",
|
|
|
|
|
|
"json_body_json": "{}",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def is_good_response(self, resp: dict) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Decide whether resp['data'] looks valid.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not resp.get("success", False):
|
|
|
|
|
|
return False
|
|
|
|
|
|
data = resp.get("data")
|
|
|
|
|
|
# simple heuristics: non-empty dict or list
|
|
|
|
|
|
return bool(data and (isinstance(data, dict) or isinstance(data, list)))
|
|
|
|
|
|
|
|
|
|
|
|
async def orchestrate_call(
|
|
|
|
|
|
self,
|
|
|
|
|
|
user_query: str,
|
|
|
|
|
|
max_calls: int = 3,
|
|
|
|
|
|
max_attempts_per_call: int = 4,
|
|
|
|
|
|
__event_emitter__=None,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
emitter = _EventEmitter(__event_emitter__)
|
|
|
|
|
|
key_points = self.extract_key_points(user_query)
|
|
|
|
|
|
spec = await self._load_spec()
|
|
|
|
|
|
|
|
|
|
|
|
candidates = self.find_candidate_endpoints(key_points, max_calls)
|
|
|
|
|
|
last_resp = {"success": False, "error": "no_attempts"}
|
|
|
|
|
|
for path in candidates:
|
|
|
|
|
|
schema = await self.get_endpoint_schema(path)
|
|
|
|
|
|
await emitter.emit(f"Trying endpoint {path}", "in_progress")
|
|
|
|
|
|
|
|
|
|
|
|
for attempt in range(1, max_attempts_per_call + 1):
|
|
|
|
|
|
args = self.build_call_args(path, schema, key_points, attempt)
|
|
|
|
|
|
resp = await self.qortal_call(__event_emitter__=__event_emitter__, **args)
|
|
|
|
|
|
resp_obj = json.loads(resp)
|
|
|
|
|
|
if self.is_good_response(resp_obj):
|
|
|
|
|
|
await emitter.emit(
|
|
|
|
|
|
f"Success on {path} attempt {attempt}", "complete", True
|
|
|
|
|
|
)
|
|
|
|
|
|
return json.dumps(resp_obj, ensure_ascii=False)
|
|
|
|
|
|
last_resp = resp_obj
|
|
|
|
|
|
await emitter.emit(
|
|
|
|
|
|
f"Attempt {attempt} failed for {path}", "error", False
|
|
|
|
|
|
)
|
|
|
|
|
|
await emitter.emit(f"Moving to next endpoint", "in_progress")
|
|
|
|
|
|
|
|
|
|
|
|
# after all attempts
|
|
|
|
|
|
await emitter.emit("All attempts exhausted; please clarify.", "error", True)
|
|
|
|
|
|
return json.dumps(last_resp, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
toolkit = Tools()
|
|
|
|
|
|
|
|
|
|
|
|
def register_workspace_tools() -> dict:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Open-WebUI will call this to learn about your functions.
|
|
|
|
|
|
Keys are the “tool names” the model invokes, values are the callables.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"qortal_openapi": toolkit.qortal_openapi,
|
|
|
|
|
|
"qortal_list_paths": toolkit.qortal_list_paths,
|
|
|
|
|
|
"qortal_describe": toolkit.qortal_describe,
|
|
|
|
|
|
"qortal_build_call": toolkit.qortal_build_call,
|
|
|
|
|
|
"qortal_call": toolkit.qortal_call,
|
|
|
|
|
|
"qortal_orchestrate":toolkit.orchestrate_call,
|
|
|
|
|
|
}
|