feat: add smdb intergration
This commit is contained in:
@@ -6,6 +6,7 @@ from typing import Any, Awaitable, Callable
|
||||
|
||||
from traderai.memory import MemoryStore
|
||||
from traderai.scheduler import WakeScheduler
|
||||
from traderai.scmdb_client import SCMDBClient
|
||||
from traderai.uex_client import UEXClient
|
||||
|
||||
|
||||
@@ -155,8 +156,10 @@ class ToolRegistry:
|
||||
require_write_approval: bool = True,
|
||||
memory: MemoryStore | None = None,
|
||||
scheduler: WakeScheduler | None = None,
|
||||
scmdb: SCMDBClient | None = None,
|
||||
) -> None:
|
||||
self.uex = uex
|
||||
self.scmdb = scmdb or SCMDBClient()
|
||||
self.require_write_approval = require_write_approval
|
||||
self.memory = memory
|
||||
self.scheduler = scheduler
|
||||
@@ -173,6 +176,9 @@ class ToolRegistry:
|
||||
"schedule_wake_job": self.schedule_wake_job,
|
||||
"list_wake_jobs": self.list_wake_jobs,
|
||||
"check_uex_notifications": self.check_uex_notifications,
|
||||
"list_scmdb_versions": self.list_scmdb_versions,
|
||||
"search_scmdb_missions": self.search_scmdb_missions,
|
||||
"get_scmdb_mission_rewards": self.get_scmdb_mission_rewards,
|
||||
}
|
||||
self.handlers["uex_api_catalog"] = self.uex_api_catalog
|
||||
self.handlers["uex_get"] = self.uex_get
|
||||
@@ -197,6 +203,7 @@ class ToolRegistry:
|
||||
*self._history_summary_schemas(),
|
||||
*self._uex_post_schemas(),
|
||||
*self._uex_delete_schemas(),
|
||||
*self._scmdb_schemas(),
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -767,6 +774,66 @@ class ToolRegistry:
|
||||
},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _scmdb_schemas(cls) -> list[dict[str, Any]]:
|
||||
version_controls = {
|
||||
"version": {"type": "string", "description": "SCMDB game-data version, such as 4.7.2-live.11715810."},
|
||||
"channel": {"type": "string", "enum": ["live", "ptu", "latest"], "default": "live"},
|
||||
}
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_scmdb_versions",
|
||||
"description": "List SCMDB mission-data versions. Use this when the user asks which Star Citizen game versions are available.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": {"type": "string", "enum": ["live", "ptu", "latest"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_scmdb_missions",
|
||||
"description": "Search SCMDB Star Citizen missions/contracts and return compact reward summaries: UEC, reputation, item, blueprint, and hauling rewards.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Text to search in title, debug name, description, faction, mission type, or reward names."},
|
||||
"mission_type": {"type": "string", "description": "Mission type such as Hauling, Delivery, Bounty Hunter, Mercenary, Racing, Salvage, or Mining."},
|
||||
"category": {"type": "string"},
|
||||
"faction": {"type": "string"},
|
||||
"system": {"type": "string", "description": "Star system such as Stanton or Pyro."},
|
||||
"illegal": {"type": "boolean"},
|
||||
"include_legacy": {"type": "boolean", "default": True},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 25, "default": 10},
|
||||
**version_controls,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_scmdb_mission_rewards",
|
||||
"description": "Fetch detailed SCMDB rewards and requirements for one Star Citizen mission/contract by id, debug name, or title.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"debug_name": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"include_legacy": {"type": "boolean", "default": True},
|
||||
**version_controls,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _uex_post_schemas(cls) -> list[dict[str, Any]]:
|
||||
return [
|
||||
@@ -980,6 +1047,125 @@ class ToolRegistry:
|
||||
pending = [item for item in notifications if not item.get("date_read")]
|
||||
return {"count": len(pending), "notifications": pending}
|
||||
|
||||
async def list_scmdb_versions(self, channel: str | None = None) -> dict[str, Any]:
|
||||
versions = await self.scmdb.list_versions()
|
||||
channel_filter = (channel or "").casefold().strip()
|
||||
if channel_filter in {"live", "ptu"}:
|
||||
versions = [
|
||||
item
|
||||
for item in versions
|
||||
if f"-{channel_filter}." in str(item.get("version", "")).casefold()
|
||||
]
|
||||
elif channel_filter not in {"", "latest"}:
|
||||
return {"error": "SCMDB channel must be live, ptu, or latest."}
|
||||
return {
|
||||
"source": self.scmdb.base_url,
|
||||
"count": len(versions),
|
||||
"versions": versions,
|
||||
"default_channel": "live",
|
||||
}
|
||||
|
||||
async def search_scmdb_missions(
|
||||
self,
|
||||
query: str = "",
|
||||
mission_type: str | None = None,
|
||||
category: str | None = None,
|
||||
faction: str | None = None,
|
||||
system: str | None = None,
|
||||
illegal: bool | None = None,
|
||||
include_legacy: bool = True,
|
||||
limit: int = 10,
|
||||
version: str | None = None,
|
||||
channel: str = "live",
|
||||
) -> dict[str, Any]:
|
||||
data = await self.scmdb.get_data(version=version, channel=channel)
|
||||
q = (query or "").casefold().strip()
|
||||
mission_type_filter = (mission_type or "").casefold().strip()
|
||||
category_filter = (category or "").casefold().strip()
|
||||
faction_filter = (faction or "").casefold().strip()
|
||||
system_filter = (system or "").casefold().strip()
|
||||
matched = []
|
||||
|
||||
for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy):
|
||||
summary = self._summarize_scmdb_mission(data, mission, source=source)
|
||||
if mission_type_filter and mission_type_filter not in str(summary.get("mission_type") or "").casefold():
|
||||
continue
|
||||
if category_filter and category_filter not in str(summary.get("category") or "").casefold():
|
||||
continue
|
||||
if faction_filter and faction_filter not in str(summary.get("faction") or "").casefold():
|
||||
continue
|
||||
if system_filter and system_filter not in " ".join(summary.get("systems") or []).casefold():
|
||||
continue
|
||||
if illegal is not None and bool(summary.get("illegal")) != illegal:
|
||||
continue
|
||||
if q and q not in self._scmdb_search_text(data, mission, summary):
|
||||
continue
|
||||
matched.append(summary)
|
||||
|
||||
limit = max(1, min(limit, 25))
|
||||
return {
|
||||
"source": self.scmdb.base_url,
|
||||
"version": data.get("version"),
|
||||
"matched": len(matched),
|
||||
"returned": min(len(matched), limit),
|
||||
"truncated": len(matched) > limit,
|
||||
"missions": matched[:limit],
|
||||
}
|
||||
|
||||
async def get_scmdb_mission_rewards(
|
||||
self,
|
||||
id: str | None = None,
|
||||
debug_name: str | None = None,
|
||||
title: str | None = None,
|
||||
include_legacy: bool = True,
|
||||
version: str | None = None,
|
||||
channel: str = "live",
|
||||
) -> dict[str, Any]:
|
||||
if not any([id, debug_name, title]):
|
||||
return {"error": "Provide id, debug_name, or title."}
|
||||
|
||||
data = await self.scmdb.get_data(version=version, channel=channel)
|
||||
exact = []
|
||||
fuzzy = []
|
||||
id_filter = (id or "").casefold().strip()
|
||||
debug_filter = (debug_name or "").casefold().strip()
|
||||
title_filter = (title or "").casefold().strip()
|
||||
|
||||
for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy):
|
||||
mission_id = str(mission.get("id") or "").casefold()
|
||||
mission_debug = str(mission.get("debugName") or "").casefold()
|
||||
mission_title = str(mission.get("title") or "").casefold()
|
||||
if id_filter and mission_id == id_filter:
|
||||
exact.append((source, mission))
|
||||
elif debug_filter and mission_debug == debug_filter:
|
||||
exact.append((source, mission))
|
||||
elif title_filter and mission_title == title_filter:
|
||||
exact.append((source, mission))
|
||||
elif title_filter and title_filter in mission_title:
|
||||
fuzzy.append((source, mission))
|
||||
elif debug_filter and debug_filter in mission_debug:
|
||||
fuzzy.append((source, mission))
|
||||
|
||||
candidates = exact or fuzzy
|
||||
if len(candidates) != 1:
|
||||
return {
|
||||
"source": self.scmdb.base_url,
|
||||
"version": data.get("version"),
|
||||
"matched": len(candidates),
|
||||
"error": "No SCMDB mission matched." if not candidates else "Multiple SCMDB missions matched; refine by id or debug_name.",
|
||||
"matches": [
|
||||
self._summarize_scmdb_mission(data, mission, source=source)
|
||||
for source, mission in candidates[:10]
|
||||
],
|
||||
}
|
||||
|
||||
source, mission = candidates[0]
|
||||
return {
|
||||
"source": self.scmdb.base_url,
|
||||
"version": data.get("version"),
|
||||
"mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True),
|
||||
}
|
||||
|
||||
def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]:
|
||||
action_id = str(uuid.uuid4())
|
||||
payload = {key: value for key, value in payload.items() if value is not None}
|
||||
@@ -1171,6 +1357,248 @@ class ToolRegistry:
|
||||
def _is_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
@staticmethod
|
||||
def _scmdb_contracts(data: dict[str, Any], include_legacy: bool = True) -> list[tuple[str, dict[str, Any]]]:
|
||||
contracts = [
|
||||
("contracts", mission)
|
||||
for mission in data.get("contracts") or []
|
||||
if isinstance(mission, dict)
|
||||
]
|
||||
if include_legacy:
|
||||
contracts.extend(
|
||||
("legacyContracts", mission)
|
||||
for mission in data.get("legacyContracts") or []
|
||||
if isinstance(mission, dict)
|
||||
)
|
||||
return contracts
|
||||
|
||||
@classmethod
|
||||
def _summarize_scmdb_mission(
|
||||
cls,
|
||||
data: dict[str, Any],
|
||||
mission: dict[str, Any],
|
||||
source: str,
|
||||
detailed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
summary: dict[str, Any] = {
|
||||
"id": mission.get("id"),
|
||||
"debug_name": mission.get("debugName"),
|
||||
"source": source,
|
||||
"title": mission.get("title"),
|
||||
"mission_type": mission.get("missionType"),
|
||||
"category": mission.get("category"),
|
||||
"faction": cls._scmdb_faction_name(data, mission.get("factionGuid")),
|
||||
"systems": mission.get("systems") or [],
|
||||
"illegal": bool(mission.get("illegal")),
|
||||
"can_be_shared": bool(mission.get("canBeShared")),
|
||||
"once_only": bool(mission.get("onceOnly")),
|
||||
"time_to_complete_minutes": mission.get("timeToComplete"),
|
||||
"max_players": mission.get("maxPlayersPerInstance"),
|
||||
"cooldown_minutes": mission.get("personalCooldownTime"),
|
||||
"min_standing": cls._scmdb_standing(mission.get("minStanding")),
|
||||
"max_standing": cls._scmdb_standing(mission.get("maxStanding")),
|
||||
"rewards": cls._scmdb_rewards(data, mission, detailed=detailed),
|
||||
}
|
||||
if detailed:
|
||||
summary.update(
|
||||
{
|
||||
"description": cls._compact_scalar(mission.get("description")),
|
||||
"locations": cls._scmdb_pool_names(data, "locationPools", mission.get("locations"), limit=20),
|
||||
"destinations": cls._scmdb_pool_names(data, "locationPools", mission.get("destinations"), limit=20),
|
||||
"prerequisites": cls._compact_scalar(mission.get("prerequisites")),
|
||||
"available_in_prison": bool(mission.get("availableInPrison")),
|
||||
"reaccept_after_abandoning": bool(mission.get("canReacceptAfterAbandoning")),
|
||||
"reaccept_after_failing": bool(mission.get("canReacceptAfterFailing")),
|
||||
"hide_in_mobiglas": bool(mission.get("hideInMobiGlas")),
|
||||
}
|
||||
)
|
||||
return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
|
||||
|
||||
@classmethod
|
||||
def _scmdb_rewards(cls, data: dict[str, Any], mission: dict[str, Any], detailed: bool = False) -> dict[str, Any]:
|
||||
rewards: dict[str, Any] = {
|
||||
"uec": mission.get("rewardUEC"),
|
||||
"buy_in": mission.get("buyIn"),
|
||||
"dynamic_uec": mission.get("rewardIsDynamic"),
|
||||
"reputation": cls._scmdb_indexed_reputation(data, mission.get("factionRewardsIndex")),
|
||||
"failure_reputation": cls._scmdb_reputation_rewards(data, mission.get("factionRewards_fail")),
|
||||
"items": cls._scmdb_item_rewards(mission.get("itemRewards")),
|
||||
"blueprints": cls._scmdb_blueprint_rewards(data, mission.get("blueprintRewards"), detailed=detailed),
|
||||
"hauling": cls._scmdb_hauling_orders(data, mission.get("haulingOrders")),
|
||||
"partial_payouts": cls._scmdb_partial_payouts(data, mission.get("partialRewardPayoutIndex")),
|
||||
}
|
||||
return {key: value for key, value in rewards.items() if value not in (None, "", [], {})}
|
||||
|
||||
@classmethod
|
||||
def _scmdb_indexed_reputation(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(index, int):
|
||||
return []
|
||||
pools = data.get("factionRewardsPools") or []
|
||||
if index < 0 or index >= len(pools):
|
||||
return []
|
||||
return cls._scmdb_reputation_rewards(data, pools[index])
|
||||
|
||||
@classmethod
|
||||
def _scmdb_reputation_rewards(cls, data: dict[str, Any], rewards: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(rewards, list):
|
||||
return []
|
||||
result = []
|
||||
for reward in rewards:
|
||||
if not isinstance(reward, dict):
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"faction": cls._scmdb_faction_name(data, reward.get("factionGuid")),
|
||||
"scope": cls._scmdb_scope_name(data, reward.get("scopeGuid")),
|
||||
"amount": reward.get("amount"),
|
||||
}
|
||||
)
|
||||
return [item for item in result if item.get("amount") not in (None, "")]
|
||||
|
||||
@classmethod
|
||||
def _scmdb_item_rewards(cls, rewards: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(rewards, list):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"name": reward.get("name"),
|
||||
"amount": reward.get("amount"),
|
||||
}
|
||||
for reward in rewards
|
||||
if isinstance(reward, dict)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _scmdb_blueprint_rewards(cls, data: dict[str, Any], rewards: Any, detailed: bool = False) -> list[dict[str, Any]]:
|
||||
if not isinstance(rewards, list):
|
||||
return []
|
||||
result = []
|
||||
for reward in rewards:
|
||||
if not isinstance(reward, dict):
|
||||
continue
|
||||
pool_id = reward.get("blueprintPool")
|
||||
pool = (data.get("blueprintPools") or {}).get(pool_id) or {}
|
||||
blueprints = pool.get("blueprints") or []
|
||||
result.append(
|
||||
{
|
||||
"pool": pool.get("name") or reward.get("poolName"),
|
||||
"chance": reward.get("chance"),
|
||||
"trigger": reward.get("trigger"),
|
||||
"blueprints": [
|
||||
cls._compact_scalar(item.get("name"))
|
||||
for item in blueprints[: 20 if detailed else 5]
|
||||
if isinstance(item, dict) and item.get("name")
|
||||
],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _scmdb_hauling_orders(cls, data: dict[str, Any], orders: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(orders, list):
|
||||
return []
|
||||
result = []
|
||||
resources = data.get("resourcePools") or {}
|
||||
for order in orders:
|
||||
if not isinstance(order, dict):
|
||||
continue
|
||||
resource = resources.get(order.get("resource")) or {}
|
||||
result.append(
|
||||
{
|
||||
"resource": resource.get("name") or order.get("resource"),
|
||||
"min_scu": order.get("minSCU"),
|
||||
"max_scu": order.get("maxSCU"),
|
||||
"max_container_size_scu": order.get("maxContainerSize"),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _scmdb_partial_payouts(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(index, int):
|
||||
return []
|
||||
pools = data.get("partialRewardPayoutPools") or []
|
||||
if index < 0 or index >= len(pools):
|
||||
return []
|
||||
payouts = pools[index]
|
||||
if not isinstance(payouts, list):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"min_percent": payout.get("minPercentage"),
|
||||
"max_percent": payout.get("maxPercentage"),
|
||||
"currency_multiplier": payout.get("currencyRewardMultiplier"),
|
||||
"reputation_multipliers": payout.get("reputationMultipliers"),
|
||||
}
|
||||
for payout in payouts
|
||||
if isinstance(payout, dict)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _scmdb_faction_name(data: dict[str, Any], guid: Any) -> str | None:
|
||||
if not guid:
|
||||
return None
|
||||
faction = (data.get("factions") or {}).get(guid)
|
||||
if isinstance(faction, dict):
|
||||
return faction.get("name") or guid
|
||||
return str(guid)
|
||||
|
||||
@staticmethod
|
||||
def _scmdb_scope_name(data: dict[str, Any], guid: Any) -> str | None:
|
||||
if not guid:
|
||||
return None
|
||||
scope = (data.get("scopes") or {}).get(guid)
|
||||
if isinstance(scope, dict):
|
||||
return scope.get("scopeName") or guid
|
||||
return str(guid)
|
||||
|
||||
@staticmethod
|
||||
def _scmdb_standing(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return {
|
||||
key: value.get(key)
|
||||
for key in ("name", "minReputation", "scopeName")
|
||||
if value.get(key) not in (None, "")
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _scmdb_pool_names(data: dict[str, Any], pool_key: str, keys: Any, limit: int = 10) -> list[str]:
|
||||
if not isinstance(keys, list):
|
||||
return []
|
||||
pool = data.get(pool_key) or {}
|
||||
names = []
|
||||
for key in keys[:limit]:
|
||||
item = pool.get(key)
|
||||
if isinstance(item, dict):
|
||||
names.append(str(item.get("name") or key))
|
||||
else:
|
||||
names.append(str(key))
|
||||
return names
|
||||
|
||||
@classmethod
|
||||
def _scmdb_search_text(cls, data: dict[str, Any], mission: dict[str, Any], summary: dict[str, Any]) -> str:
|
||||
pieces = [
|
||||
summary.get("title"),
|
||||
summary.get("debug_name"),
|
||||
summary.get("mission_type"),
|
||||
summary.get("category"),
|
||||
summary.get("faction"),
|
||||
mission.get("description"),
|
||||
" ".join(summary.get("systems") or []),
|
||||
]
|
||||
rewards = summary.get("rewards") or {}
|
||||
pieces.append(str(rewards.get("uec") or ""))
|
||||
for item in rewards.get("reputation") or []:
|
||||
pieces.extend([item.get("faction"), item.get("scope"), item.get("amount")])
|
||||
for item in rewards.get("items") or []:
|
||||
pieces.extend([item.get("name"), item.get("amount")])
|
||||
for item in rewards.get("blueprints") or []:
|
||||
pieces.extend([item.get("pool"), " ".join(item.get("blueprints") or [])])
|
||||
for item in rewards.get("hauling") or []:
|
||||
pieces.extend([item.get("resource"), item.get("min_scu"), item.get("max_scu")])
|
||||
return " ".join(str(piece) for piece in pieces if piece not in (None, "")).casefold()
|
||||
|
||||
@staticmethod
|
||||
def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user