mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 00:43:18 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
|
|
@ -9,21 +9,4 @@ def system_info_tool() -> ToolSpec:
|
|||
return factory()
|
||||
|
||||
|
||||
def geo_info_tool() -> ToolSpec:
|
||||
from .geo_info import geo_info_tool as factory
|
||||
|
||||
return factory()
|
||||
|
||||
|
||||
def weather_tool() -> ToolSpec:
|
||||
from .weather import weather_tool as factory
|
||||
|
||||
return factory()
|
||||
|
||||
|
||||
def web_search_tool() -> ToolSpec:
|
||||
from .web_search import web_search_tool as factory
|
||||
|
||||
return factory()
|
||||
|
||||
__all__ = ["system_info_tool", "geo_info_tool", "weather_tool", "web_search_tool"]
|
||||
__all__ = ["system_info_tool"]
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
"""系统工具共用 HTTP 辅助函数(Nominatim 逆地理编码与 ipapi.co)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
NOMINATIM_REQUEST_HEADERS = {"User-Agent": "OpsAssistant/1.0 (internal tool)"}
|
||||
DEFAULT_HTTP_TIMEOUT = 10.0
|
||||
|
||||
|
||||
def nominatim_reverse(
|
||||
client: httpx.Client,
|
||||
lat: float,
|
||||
lon: float,
|
||||
*,
|
||||
accept_language: str = "en",
|
||||
) -> dict[str, Any]:
|
||||
"""调用 Nominatim 逆地理编码并返回解析后的 JSON,失败时返回空字典。"""
|
||||
try:
|
||||
r = client.get(
|
||||
"https://nominatim.openstreetmap.org/reverse",
|
||||
params={"lat": lat, "lon": lon, "format": "json", "accept-language": accept_language},
|
||||
headers=NOMINATIM_REQUEST_HEADERS,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def ipapi_approximate_location(client: httpx.Client) -> dict[str, Any] | None:
|
||||
try:
|
||||
ip_resp = client.get("https://ipapi.co/json/")
|
||||
ip_resp.raise_for_status()
|
||||
ip_data = ip_resp.json()
|
||||
lat = ip_data.get("latitude")
|
||||
lon = ip_data.get("longitude")
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
lat_f = float(lat)
|
||||
lon_f = float(lon)
|
||||
geo = nominatim_reverse(client, lat_f, lon_f)
|
||||
display_name = geo.get("display_name") if geo else None
|
||||
if not display_name or not str(display_name).strip():
|
||||
parts = [ip_data.get("city"), ip_data.get("region"), ip_data.get("country_name")]
|
||||
display_name = ", ".join(str(p) for p in parts if p)
|
||||
if not display_name:
|
||||
display_name = f"Approximate ({lat_f:.4f}, {lon_f:.4f})"
|
||||
return {
|
||||
"latitude": lat_f,
|
||||
"longitude": lon_f,
|
||||
"display_name": str(display_name).strip(),
|
||||
"ip": ip_data.get("ip"),
|
||||
"city": ip_data.get("city"),
|
||||
"region": ip_data.get("region"),
|
||||
"country_name": ip_data.get("country_name"),
|
||||
"nominatim": geo,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_HTTP_TIMEOUT", "NOMINATIM_REQUEST_HEADERS", "ipapi_approximate_location", "nominatim_reverse"]
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.tools.base import ToolSpec
|
||||
from .geo_http import DEFAULT_HTTP_TIMEOUT, ipapi_approximate_location, nominatim_reverse
|
||||
|
||||
|
||||
def _reverse_geocode(lat: float, lon: float) -> dict[str, Any]:
|
||||
with httpx.Client(timeout=DEFAULT_HTTP_TIMEOUT) as client:
|
||||
return nominatim_reverse(client, lat, lon)
|
||||
|
||||
|
||||
def geo_info_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
lat = args.get("latitude")
|
||||
lon = args.get("longitude")
|
||||
if lat is None or lon is None:
|
||||
return {"ok": False, "error": "latitude and longitude are required"}
|
||||
try:
|
||||
lat_f = float(lat)
|
||||
lon_f = float(lon)
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "error": "latitude and longitude must be numbers"}
|
||||
data = _reverse_geocode(lat_f, lon_f)
|
||||
if not data or "error" in data:
|
||||
error_msg = data.get("error") if data else "Unknown error"
|
||||
return {"ok": False, "error": error_msg}
|
||||
return {"ok": True, "address": data.get("display_name"), "details": data.get("address"), "latitude": lat_f, "longitude": lon_f}
|
||||
|
||||
return ToolSpec(
|
||||
name="reverse_geocode",
|
||||
description="Reverse geocode: get a human-readable address from latitude and longitude.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"latitude": {"type": "number", "description": "Latitude in decimal degrees."},
|
||||
"longitude": {"type": "number", "description": "Longitude in decimal degrees."},
|
||||
},
|
||||
"required": ["latitude", "longitude"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
|
||||
def system_location_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
with httpx.Client(timeout=DEFAULT_HTTP_TIMEOUT) as client:
|
||||
loc = ipapi_approximate_location(client)
|
||||
if not loc:
|
||||
return {"ok": False, "error": "Could not detect coordinates for this network"}
|
||||
geo = loc.get("nominatim") or {}
|
||||
return {
|
||||
"ok": True,
|
||||
"latitude": loc["latitude"],
|
||||
"longitude": loc["longitude"],
|
||||
"address": loc["display_name"],
|
||||
"ip": loc.get("ip"),
|
||||
"city": loc.get("city"),
|
||||
"region": loc.get("region"),
|
||||
"country": loc.get("country_name"),
|
||||
"details": geo.get("address") if geo else None,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Failed to detect location: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="get_system_location",
|
||||
description="Detect this machine's public IP and approximate location (coordinates and address).",
|
||||
parameters={"type": "object", "properties": {}, "additionalProperties": False},
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["geo_info_tool", "system_location_tool"]
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.tools.base import ToolSpec
|
||||
from .geo_http import NOMINATIM_REQUEST_HEADERS, ipapi_approximate_location, nominatim_reverse
|
||||
|
||||
_WEATHER_CODES: dict[int, str] = {
|
||||
0: "Clear sky",
|
||||
1: "Mainly clear",
|
||||
2: "Partly cloudy",
|
||||
3: "Overcast",
|
||||
45: "Fog",
|
||||
48: "Depositing rime fog",
|
||||
51: "Light drizzle",
|
||||
53: "Moderate drizzle",
|
||||
55: "Dense drizzle",
|
||||
61: "Slight rain",
|
||||
63: "Moderate rain",
|
||||
65: "Heavy rain",
|
||||
71: "Slight snow",
|
||||
73: "Moderate snow",
|
||||
75: "Heavy snow",
|
||||
95: "Thunderstorm",
|
||||
}
|
||||
_LOCAL_WEATHER_ALIASES: frozenset[str] = frozenset(
|
||||
{"here", "local", "locally", "nearby", "current", "current location", "my location", "this location", "local area", "unknown", "anywhere", "本地", "当地", "这里", "附近", "当前位置", "当前", "本地天气"}
|
||||
)
|
||||
|
||||
|
||||
def _normalize_city_token(s: str) -> str:
|
||||
t = unicodedata.normalize("NFKC", (s or "").strip()).casefold()
|
||||
t = re.sub(r"\s+", " ", t)
|
||||
return t
|
||||
|
||||
|
||||
def _is_local_weather_alias(city: str) -> bool:
|
||||
return _normalize_city_token(city) in _LOCAL_WEATHER_ALIASES
|
||||
|
||||
|
||||
def _coerce_city(raw: Any) -> str | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, str):
|
||||
raw = str(raw)
|
||||
s = raw.strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
def weather_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
city = _coerce_city(args.get("city"))
|
||||
lat = args.get("latitude")
|
||||
lon = args.get("longitude")
|
||||
if (lat is None) ^ (lon is None):
|
||||
return {"ok": False, "error": "Provide both latitude and longitude, or neither (for local-IP weather), or use city alone."}
|
||||
has_coords = lat is not None and lon is not None
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=12.0) as client:
|
||||
location_basis: str
|
||||
resolved_city: str
|
||||
lat_f: float
|
||||
lon_f: float
|
||||
extra: dict[str, Any] = {}
|
||||
if has_coords:
|
||||
lat_f = float(lat)
|
||||
lon_f = float(lon)
|
||||
location_basis = "explicit_coordinates"
|
||||
rev = nominatim_reverse(client, lat_f, lon_f)
|
||||
dn = (rev.get("display_name") or "").strip() if rev else ""
|
||||
resolved_city = dn or f"Coordinates ({lat_f}, {lon_f})"
|
||||
elif city and not _is_local_weather_alias(city):
|
||||
geo_resp = client.get(
|
||||
"https://nominatim.openstreetmap.org/search",
|
||||
params={"q": city, "format": "json", "limit": 1},
|
||||
headers=NOMINATIM_REQUEST_HEADERS,
|
||||
)
|
||||
geo_resp.raise_for_status()
|
||||
geo_data = geo_resp.json()
|
||||
if not geo_data:
|
||||
return {"ok": False, "error": f"City not found: {city}"}
|
||||
first = geo_data[0]
|
||||
lat_f = float(first["lat"])
|
||||
lon_f = float(first["lon"])
|
||||
resolved_city = first.get("display_name", city)
|
||||
location_basis = "explicit_place"
|
||||
else:
|
||||
ip_loc = ipapi_approximate_location(client)
|
||||
if not ip_loc:
|
||||
return {"ok": False, "error": "Could not resolve local weather: failed to detect location from this network. Pass a concrete city/region (e.g. 北京) or both latitude and longitude."}
|
||||
lat_f = ip_loc["latitude"]
|
||||
lon_f = ip_loc["longitude"]
|
||||
resolved_city = ip_loc["display_name"]
|
||||
location_basis = "local_network_ip"
|
||||
if ip_loc.get("ip") is not None:
|
||||
extra["approximate_ip"] = ip_loc["ip"]
|
||||
|
||||
weather_url = "https://api.open-meteo.com/v1/forecast"
|
||||
weather_params = {
|
||||
"latitude": lat_f,
|
||||
"longitude": lon_f,
|
||||
"current": ["temperature_2m", "relative_humidity_2m", "apparent_temperature", "is_day", "weather_code", "wind_speed_10m"],
|
||||
"timezone": "auto",
|
||||
}
|
||||
w_resp = client.get(weather_url, params=weather_params)
|
||||
w_resp.raise_for_status()
|
||||
current = w_resp.json().get("current", {})
|
||||
code = int(current.get("weather_code") or 0)
|
||||
condition = _WEATHER_CODES.get(code, "Unknown")
|
||||
out: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"city": resolved_city,
|
||||
"temperature": f"{current.get('temperature_2m')}°C",
|
||||
"feels_like": f"{current.get('apparent_temperature')}°C",
|
||||
"condition": condition,
|
||||
"humidity": f"{current.get('relative_humidity_2m')}%",
|
||||
"wind_speed": f"{current.get('wind_speed_10m')} km/h",
|
||||
"is_day": bool(current.get("is_day")),
|
||||
"latitude": lat_f,
|
||||
"longitude": lon_f,
|
||||
"location_basis": location_basis,
|
||||
}
|
||||
out.update(extra)
|
||||
if location_basis == "local_network_ip":
|
||||
out["disclaimer"] = "Weather is for the approximate location of this deployment's public IP (VPN/proxy/corporate NAT may differ from the end user's actual place)."
|
||||
return out
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Failed to fetch weather: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="get_weather",
|
||||
description="Get current weather (Open-Meteo, no API key). Default: omit city and coordinates — uses this server's outbound public IP for approximate local weather. Override: pass a concrete placename in `city` or both `latitude` and `longitude`.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string", "description": "Optional place name."},
|
||||
"latitude": {"type": "number", "description": "Optional. Must pair with longitude."},
|
||||
"longitude": {"type": "number", "description": "Optional. Must pair with latitude."},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["weather_tool"]
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
"""基于 DuckDuckGo(ddgs 包)的公网搜索工具(无需 API Key)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.tools.base import ToolSpec
|
||||
|
||||
_MAX_SNIPPET = 800
|
||||
_DDGS_TIMEOUT = 15
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _truncate(s: str, limit: int) -> str:
|
||||
t = (s or "").strip()
|
||||
if len(t) <= limit:
|
||||
return t
|
||||
return t[: limit - 3] + "..."
|
||||
|
||||
|
||||
def _published_display_and_sort_key(raw: Any) -> tuple[str | None, float]:
|
||||
if raw is None:
|
||||
return None, float("-inf")
|
||||
if isinstance(raw, (int, float)):
|
||||
try:
|
||||
ts = float(raw)
|
||||
dt = datetime.fromtimestamp(ts, timezone.utc)
|
||||
return dt.isoformat(), ts
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return str(raw), float("-inf")
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None, float("-inf")
|
||||
try:
|
||||
s2 = s[:-1] + "+00:00" if s.endswith("Z") else s
|
||||
dt = datetime.fromisoformat(s2)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
iso = dt.astimezone(timezone.utc).isoformat()
|
||||
return iso, dt.timestamp()
|
||||
except Exception:
|
||||
return s, float("-inf")
|
||||
|
||||
|
||||
def web_search_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
q = str(args.get("query") or "").strip()
|
||||
if not q:
|
||||
return {"ok": False, "error": "query is required"}
|
||||
raw_max = args.get("max_results")
|
||||
try:
|
||||
max_n = int(raw_max) if raw_max is not None else 8
|
||||
except (TypeError, ValueError):
|
||||
max_n = 8
|
||||
max_n = max(1, min(15, max_n))
|
||||
stype = str(args.get("search_type") or "web").strip().lower()
|
||||
if stype not in ("web", "news"):
|
||||
return {"ok": False, "error": "search_type must be 'web' or 'news'"}
|
||||
timelimit = args.get("time_range")
|
||||
if timelimit is not None and timelimit != "":
|
||||
tl = str(timelimit).strip().lower()
|
||||
allowed = {"d", "w", "m", "y"}
|
||||
if tl not in allowed:
|
||||
return {"ok": False, "error": f"time_range must be one of {sorted(allowed)} or omitted"}
|
||||
timelimit = tl
|
||||
else:
|
||||
timelimit = None
|
||||
try:
|
||||
from ddgs import DDGS
|
||||
except ImportError:
|
||||
return {"ok": False, "error": "Package `ddgs` is not installed. Run: pip install ddgs"}
|
||||
|
||||
retrieved_at = _utc_now_iso()
|
||||
try:
|
||||
rows: list[dict[str, Any]] = []
|
||||
with DDGS(timeout=_DDGS_TIMEOUT) as ddgs:
|
||||
if stype == "web":
|
||||
for r in ddgs.text(q, max_results=max_n, timelimit=timelimit):
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
title = _truncate(str(r.get("title") or ""), 300)
|
||||
url = str(r.get("href") or r.get("url") or "").strip()
|
||||
body = _truncate(str(r.get("body") or ""), _MAX_SNIPPET)
|
||||
if title or url or body:
|
||||
rows.append({"title": title, "url": url, "snippet": body, "published_time": None})
|
||||
sort_mode = "relevance"
|
||||
note = "Web index does not provide reliable per-result publication times; order follows search relevance. Use search_type=news for time-sorted news."
|
||||
else:
|
||||
decorated: list[tuple[float, dict[str, Any]]] = []
|
||||
for r in ddgs.news(q, max_results=max_n, timelimit=timelimit):
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
title = _truncate(str(r.get("title") or ""), 300)
|
||||
url = str(r.get("url") or r.get("href") or "").strip()
|
||||
body = _truncate(str(r.get("body") or ""), _MAX_SNIPPET)
|
||||
pub, sk = _published_display_and_sort_key(r.get("date"))
|
||||
src = str(r.get("source") or "").strip()
|
||||
item = {"title": title, "url": url, "snippet": body, "published_time": pub}
|
||||
if src:
|
||||
item["source"] = src
|
||||
if title or url or body:
|
||||
decorated.append((sk, item))
|
||||
decorated.sort(key=lambda x: x[0], reverse=True)
|
||||
rows = [x[1] for x in decorated]
|
||||
sort_mode = "published_time_desc"
|
||||
note = "News results sorted by published_time (newest first). Snippets are from third-party indexes; verify critical facts."
|
||||
|
||||
if not rows:
|
||||
return {"ok": True, "query": q, "search_type": stype, "retrieved_at": retrieved_at, "sort": sort_mode, "results": [], "note": "No results (empty or blocked). Try rephrasing the query."}
|
||||
return {"ok": True, "query": q, "search_type": stype, "retrieved_at": retrieved_at, "sort": sort_mode, "results": rows, "source": "duckduckgo", "note": note}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Web search failed: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="web_search",
|
||||
description="Search the public web (DuckDuckGo via ddgs, no API key).",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search keywords or question."},
|
||||
"max_results": {"type": "integer", "description": "Optional. Number of results (1–15). Default 8."},
|
||||
"search_type": {"type": "string", "enum": ["web", "news"], "description": "Optional. 'web' or 'news'."},
|
||||
"time_range": {"type": "string", "enum": ["d", "w", "m", "y"], "description": "Optional time limit."},
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["web_search_tool"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue