feat(goals): add GoalDecomposer — pure-logic task planner from mission+plugins
Introduces the GoalDecomposer class that bridges mission.strategy + plugin capabilities into concrete, weighted Task objects. Each Task has a target screen, budget, weight, and human-readable intent. Key design decisions: - Pure logic, zero LLM/device dependencies - Strategy weights (aggressive_growth, community_builder, etc.) drive selection - Plugins declare which screens they operate on (multi-screen map) - Screens need BOTH an action route AND active plugin to be viable - Frozen dataclass ensures Task immutability 12/12 TDD tests passing.
This commit is contained in:
276
GramAddict/core/goal_decomposer.py
Normal file
276
GramAddict/core/goal_decomposer.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
GoalDecomposer — Mission-Driven Task Planning
|
||||
|
||||
Translates the bot's `mission` config + `plugins` capabilities into
|
||||
concrete, weighted Task objects. Pure logic — no LLM, no device,
|
||||
no network, no side effects.
|
||||
|
||||
This is the bridge between:
|
||||
- "What does the user WANT?" (mission.strategy)
|
||||
- "What CAN the bot DO?" (enabled plugins + actions)
|
||||
- "What SHOULD it do NOW?" (weighted Task selection)
|
||||
|
||||
Tesla analogy: FSD doesn't have a "goal: drive safely" config.
|
||||
It derives behavior from destination + road rules + sensor capabilities.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Strategy Weight Tables ──
|
||||
# Each strategy defines relative weights for screen targets.
|
||||
# Higher weight = more likely to be selected by GrowthBrain.
|
||||
STRATEGY_WEIGHTS: Dict[str, Dict[str, float]] = {
|
||||
"aggressive_growth": {
|
||||
"HomeFeed": 0.15,
|
||||
"ExploreFeed": 0.45,
|
||||
"ReelsFeed": 0.15,
|
||||
"StoriesFeed": 0.10,
|
||||
"MessageInbox": 0.10,
|
||||
"FollowingList": 0.05,
|
||||
},
|
||||
"community_builder": {
|
||||
"HomeFeed": 0.40,
|
||||
"ExploreFeed": 0.10,
|
||||
"ReelsFeed": 0.05,
|
||||
"StoriesFeed": 0.25,
|
||||
"MessageInbox": 0.15,
|
||||
"FollowingList": 0.05,
|
||||
},
|
||||
"passive_learning": {
|
||||
"HomeFeed": 0.20,
|
||||
"ExploreFeed": 0.50,
|
||||
"ReelsFeed": 0.20,
|
||||
"StoriesFeed": 0.05,
|
||||
"MessageInbox": 0.00,
|
||||
"FollowingList": 0.05,
|
||||
},
|
||||
"stealth_lurker": {
|
||||
"HomeFeed": 0.35,
|
||||
"ExploreFeed": 0.25,
|
||||
"ReelsFeed": 0.15,
|
||||
"StoriesFeed": 0.15,
|
||||
"MessageInbox": 0.05,
|
||||
"FollowingList": 0.05,
|
||||
},
|
||||
}
|
||||
|
||||
# ── Plugin → Screen Mapping ──
|
||||
# Which plugins enable which screen targets.
|
||||
# A screen is only viable if at least one enabling plugin is active.
|
||||
# Some plugins work on MULTIPLE screens (likes work on home, explore, reels).
|
||||
PLUGIN_SCREENS_MAP: Dict[str, set] = {
|
||||
"likes": {"HomeFeed", "ExploreFeed", "ReelsFeed"},
|
||||
"comment": {"HomeFeed", "ExploreFeed"},
|
||||
"follow": {"HomeFeed", "ExploreFeed"},
|
||||
"repost": {"HomeFeed", "ExploreFeed"},
|
||||
"profile_visit": {"HomeFeed", "ExploreFeed"},
|
||||
"grid_like": {"HomeFeed"},
|
||||
"carousel_browsing": {"HomeFeed"},
|
||||
"rabbit_hole": {"HomeFeed", "ExploreFeed"},
|
||||
"story_view": {"StoriesFeed"},
|
||||
"dm_reply": {"MessageInbox"},
|
||||
}
|
||||
|
||||
# ── Action → Screen Mapping ──
|
||||
# The `actions:` config section maps directly to screens.
|
||||
ACTION_SCREEN_MAP: Dict[str, str] = {
|
||||
"feed": "HomeFeed",
|
||||
"explore": "ExploreFeed",
|
||||
"reels": "ReelsFeed",
|
||||
}
|
||||
|
||||
# ── Screen → Verb Mapping ──
|
||||
SCREEN_VERB_MAP: Dict[str, str] = {
|
||||
"HomeFeed": "browse_feed",
|
||||
"ExploreFeed": "browse_explore",
|
||||
"ReelsFeed": "browse_reels",
|
||||
"StoriesFeed": "view_stories",
|
||||
"MessageInbox": "check_messages",
|
||||
"FollowingList": "manage_following",
|
||||
}
|
||||
|
||||
# ── Screen → Human Intent ──
|
||||
SCREEN_INTENT_MAP: Dict[str, str] = {
|
||||
"HomeFeed": "Interact with posts in the home feed",
|
||||
"ExploreFeed": "Discover and engage with new content",
|
||||
"ReelsFeed": "Browse and interact with reels",
|
||||
"StoriesFeed": "View and react to stories",
|
||||
"MessageInbox": "Reply to unread direct messages",
|
||||
"FollowingList": "Review and manage following list",
|
||||
}
|
||||
|
||||
DEFAULT_BUDGET = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Task:
|
||||
"""A concrete, executable unit of work for the bot.
|
||||
|
||||
Unlike abstract goals ("nurture community"), a Task has:
|
||||
- A specific screen to navigate to
|
||||
- A measurable budget (how many posts/items to process)
|
||||
- A weight for probabilistic selection
|
||||
- A human-readable intent for logging
|
||||
"""
|
||||
|
||||
verb: str
|
||||
target_screen: str
|
||||
intent: str
|
||||
budget_posts: int
|
||||
weight: float
|
||||
|
||||
|
||||
class GoalDecomposer:
|
||||
"""Translates mission + plugins → weighted Task list.
|
||||
|
||||
Pure logic, zero side effects. Call generate_tasks() to get
|
||||
the bot's action menu for the current session.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugins: Dict[str, dict],
|
||||
actions: Dict[str, str],
|
||||
mission: Dict[str, str],
|
||||
):
|
||||
self._plugins = plugins
|
||||
self._actions = actions
|
||||
self._strategy = mission.get("strategy", "aggressive_growth")
|
||||
|
||||
def generate_tasks(self) -> List[Task]:
|
||||
"""Generate weighted tasks from config.
|
||||
|
||||
Returns an empty list if no plugins are enabled —
|
||||
the bot literally has nothing to do.
|
||||
"""
|
||||
viable_screens = self._discover_viable_screens()
|
||||
if not viable_screens:
|
||||
return []
|
||||
|
||||
strategy_weights = STRATEGY_WEIGHTS.get(self._strategy, STRATEGY_WEIGHTS["aggressive_growth"])
|
||||
|
||||
tasks = []
|
||||
for screen in viable_screens:
|
||||
weight = strategy_weights.get(screen, 0.1)
|
||||
if weight <= 0:
|
||||
continue
|
||||
|
||||
budget = self._budget_for_screen(screen)
|
||||
verb = SCREEN_VERB_MAP.get(screen, "browse")
|
||||
intent = SCREEN_INTENT_MAP.get(screen, f"Interact on {screen}")
|
||||
|
||||
tasks.append(
|
||||
Task(
|
||||
verb=verb,
|
||||
target_screen=screen,
|
||||
intent=intent,
|
||||
budget_posts=budget,
|
||||
weight=weight,
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
|
||||
def _discover_viable_screens(self) -> set:
|
||||
"""Determine which screens the bot can meaningfully interact on.
|
||||
|
||||
A screen is viable if it has BOTH:
|
||||
1. A route (action config or plugin-implied), AND
|
||||
2. At least one active plugin that can DO something there.
|
||||
|
||||
Without an active plugin, navigating to a screen is pointless —
|
||||
the bot would just scroll with nothing to interact on.
|
||||
"""
|
||||
# 1. Collect screens with active plugins
|
||||
plugin_screens: set = set()
|
||||
for plugin_name, screens in PLUGIN_SCREENS_MAP.items():
|
||||
plugin_cfg = self._plugins.get(plugin_name, {})
|
||||
if not plugin_cfg:
|
||||
continue
|
||||
if not self._is_plugin_active(plugin_cfg):
|
||||
continue
|
||||
plugin_screens.update(screens)
|
||||
|
||||
# 2. Screens from actions are only viable if plugins exist for them
|
||||
action_screens: set = set()
|
||||
for action_key, screen in ACTION_SCREEN_MAP.items():
|
||||
if action_key in self._actions and self._actions[action_key]:
|
||||
action_screens.add(screen)
|
||||
|
||||
# 3. A screen must have plugin coverage to be viable
|
||||
# Action-enabled screens need at least one active plugin
|
||||
viable = action_screens & plugin_screens
|
||||
|
||||
# 4. Plugin-only screens (story_view, dm_reply) are viable
|
||||
# even without an explicit action config
|
||||
viable |= plugin_screens
|
||||
|
||||
return viable
|
||||
|
||||
def _is_plugin_active(self, plugin_cfg: dict) -> bool:
|
||||
"""Check if a plugin config represents an active plugin.
|
||||
|
||||
A plugin is active if:
|
||||
- It has `enabled: true` (explicit), OR
|
||||
- It has `percentage` > 0 (implicit enable), OR
|
||||
- It has any config keys and `enabled` is not explicitly False
|
||||
"""
|
||||
# Explicit disable
|
||||
if plugin_cfg.get("enabled") is False:
|
||||
return False
|
||||
|
||||
# Explicit enable
|
||||
if plugin_cfg.get("enabled") is True:
|
||||
return True
|
||||
|
||||
# Percentage-based: 0% means disabled
|
||||
pct = plugin_cfg.get("percentage")
|
||||
if pct is not None:
|
||||
try:
|
||||
return float(pct) > 0
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
# Has config keys but no explicit enabled/percentage = active
|
||||
return bool(plugin_cfg)
|
||||
|
||||
def _budget_for_screen(self, screen: str) -> int:
|
||||
"""Determine the post budget for a screen.
|
||||
|
||||
Reads from actions config (e.g. feed: "5-10") and parses
|
||||
the range string into a random integer within bounds.
|
||||
"""
|
||||
# Map screen back to action key
|
||||
reverse_map = {v: k for k, v in ACTION_SCREEN_MAP.items()}
|
||||
action_key = reverse_map.get(screen)
|
||||
|
||||
if action_key and action_key in self._actions:
|
||||
return _parse_range(self._actions[action_key])
|
||||
|
||||
# Special screens get fixed budgets from plugin config
|
||||
if screen == "StoriesFeed":
|
||||
story_cfg = self._plugins.get("story_view", {})
|
||||
count_str = story_cfg.get("count", "1-3")
|
||||
return _parse_range(str(count_str))
|
||||
|
||||
if screen == "MessageInbox":
|
||||
return DEFAULT_BUDGET
|
||||
|
||||
return DEFAULT_BUDGET
|
||||
|
||||
|
||||
def _parse_range(range_str: str) -> int:
|
||||
"""Parse a range string like '5-10' into a random int within bounds."""
|
||||
try:
|
||||
if "-" in str(range_str):
|
||||
parts = str(range_str).split("-")
|
||||
low, high = int(parts[0]), int(parts[1])
|
||||
return random.randint(low, high)
|
||||
return int(range_str)
|
||||
except (ValueError, IndexError):
|
||||
return DEFAULT_BUDGET
|
||||
Reference in New Issue
Block a user