From aa545f99176914a839d77c54e32f06e4b52c4185 Mon Sep 17 00:00:00 2001 From: esafwan Date: Wed, 27 May 2026 04:54:19 +0400 Subject: [PATCH 01/12] feat: add Frappe CRM, Helpdesk, and Raven integration tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new internal Frappe app tool modules with full CRUD and business-automation coverage: - huf/ai/tools/crm.py: 10 tools for Frappe CRM — lead/deal lifecycle (get, create, update), notes, tasks, and contact lookup - huf/ai/tools/helpdesk.py: 8 tools for Frappe Helpdesk — ticket management, comments, agent/team listing, and assignment - huf/ai/tools/raven.py: 6 tools for Frappe Raven — send/search messages, list/create channels, channel membership All tools use direct Frappe API calls (no httpx/credentials) and guard against the app not being installed. Registry and install seeding updated. --- huf/ai/tools/_registry.py | 359 +++++++++++++++++++++++++++ huf/ai/tools/crm.py | 503 ++++++++++++++++++++++++++++++++++++++ huf/ai/tools/helpdesk.py | 285 +++++++++++++++++++++ huf/ai/tools/raven.py | 274 +++++++++++++++++++++ huf/install.py | 1 + 5 files changed, 1422 insertions(+) create mode 100644 huf/ai/tools/crm.py create mode 100644 huf/ai/tools/helpdesk.py create mode 100644 huf/ai/tools/raven.py diff --git a/huf/ai/tools/_registry.py b/huf/ai/tools/_registry.py index 7b9ac02d..a8df9e7d 100644 --- a/huf/ai/tools/_registry.py +++ b/huf/ai/tools/_registry.py @@ -217,6 +217,362 @@ def _p(name, type="string", required=False, description=""): }, ] +# --------------------------------------------------------------------------- +# CRM Tools +# --------------------------------------------------------------------------- + +CRM_TOOLS = [ + { + "tool_name": "crm_get_leads", + "description": "List CRM leads with optional filters (status, assigned_to, search). Returns paginated list with key fields.", + "function_path": "huf.ai.tools.crm.handle_get_leads", + "category": "CRM Tools", + "parameters": [ + _p("status", description="Filter by lead status"), + _p("assigned_to", description="Filter by lead owner email"), + _p("search", description="Search across first_name, last_name, email, organization"), + _p("limit", type="integer", description="Max leads to fetch (default 20)"), + _p("offset", type="integer", description="Offset for pagination (default 0)"), + ], + }, + { + "tool_name": "crm_get_lead", + "description": "Get a single CRM lead by name/ID with all fields.", + "function_path": "huf.ai.tools.crm.handle_get_lead", + "category": "CRM Tools", + "parameters": [ + _p("name", required=True, description="Lead ID/name"), + ], + }, + { + "tool_name": "crm_create_lead", + "description": "Create a new CRM lead. Optionally provide notes to create a linked note.", + "function_path": "huf.ai.tools.crm.handle_create_lead", + "category": "CRM Tools", + "parameters": [ + _p("first_name", required=True, description="First name of the lead"), + _p("last_name", description="Last name of the lead"), + _p("email", description="Email address"), + _p("mobile_no", description="Mobile number"), + _p("lead_owner", description="User email who owns this lead"), + _p("source", description="Lead source"), + _p("organization", description="Organization/company name"), + _p("notes", description="Additional notes (creates a linked FCRM Note)"), + ], + }, + { + "tool_name": "crm_update_lead", + "description": "Update fields on an existing CRM lead.", + "function_path": "huf.ai.tools.crm.handle_update_lead", + "category": "CRM Tools", + "parameters": [ + _p("name", required=True, description="Lead ID/name"), + _p("lead_name", description="Computed lead name (auto-generated if not set)"), + _p("status", description="Lead status"), + _p("lead_owner", description="Lead owner email"), + _p("first_name", description="First name"), + _p("last_name", description="Last name"), + _p("email", description="Email"), + _p("mobile_no", description="Mobile number"), + _p("organization", description="Organization"), + _p("website", description="Website"), + _p("job_title", description="Job title"), + _p("industry", description="Industry"), + _p("source", description="Source"), + _p("territory", description="Territory"), + _p("details", description="Details / description"), + _p("converted", type="Check", description="Mark as converted"), + _p("lost_reason", description="Lost reason"), + _p("lost_notes", description="Lost notes"), + ], + }, + { + "tool_name": "crm_get_deals", + "description": "List CRM deals with optional filters (status, assigned_to, organization, search).", + "function_path": "huf.ai.tools.crm.handle_get_deals", + "category": "CRM Tools", + "parameters": [ + _p("status", description="Filter by deal status"), + _p("assigned_to", description="Filter by deal owner email"), + _p("organization", description="Filter by organization name"), + _p("search", description="Search across organization and lead name"), + _p("limit", type="integer", description="Max deals to fetch (default 20)"), + _p("offset", type="integer", description="Offset for pagination (default 0)"), + ], + }, + { + "tool_name": "crm_create_deal", + "description": "Create a CRM deal from a lead or standalone. Provide either lead or organization.", + "function_path": "huf.ai.tools.crm.handle_create_deal", + "category": "CRM Tools", + "parameters": [ + _p("lead", description="Lead ID to convert into a deal"), + _p("organization", description="Organization name (required if lead is not provided)"), + _p("deal_owner", description="Deal owner email"), + _p("status", description="Deal status"), + _p("deal_value", type="number", description="Deal value amount"), + _p("probability", type="number", description="Probability of closing (0-100)"), + _p("expected_closure_date", description="Expected closure date (YYYY-MM-DD)"), + _p("next_step", description="Next step description"), + _p("currency", description="Currency code"), + ], + }, + { + "tool_name": "crm_update_deal", + "description": "Update an existing CRM deal (status, value, probability, close_date, etc.).", + "function_path": "huf.ai.tools.crm.handle_update_deal", + "category": "CRM Tools", + "parameters": [ + _p("name", required=True, description="Deal ID/name"), + _p("status", description="Deal status"), + _p("deal_value", type="number", description="Deal value"), + _p("probability", type="number", description="Probability"), + _p("expected_closure_date", description="Expected closure date"), + _p("closed_date", description="Actual closed date"), + _p("deal_owner", description="Deal owner email"), + _p("organization", description="Organization"), + _p("next_step", description="Next step"), + _p("currency", description="Currency"), + _p("lost_reason", description="Lost reason"), + _p("lost_notes", description="Lost notes"), + ], + }, + { + "tool_name": "crm_add_note", + "description": "Add a note to a CRM lead or deal.", + "function_path": "huf.ai.tools.crm.handle_add_note", + "category": "CRM Tools", + "parameters": [ + _p("doctype", required=True, description="Target DocType: CRM Lead or CRM Deal"), + _p("docname", required=True, description="Target document name/ID"), + _p("content", required=True, description="Note content"), + _p("title", description="Note title (default 'Note')"), + ], + }, + { + "tool_name": "crm_add_task", + "description": "Create a task linked to a CRM lead or deal.", + "function_path": "huf.ai.tools.crm.handle_add_task", + "category": "CRM Tools", + "parameters": [ + _p("reference_doctype", required=True, description="CRM Lead or CRM Deal"), + _p("reference_docname", required=True, description="Document name/ID"), + _p("title", required=True, description="Task title"), + _p("description", description="Task description"), + _p("assigned_to", description="Assigned user email"), + _p("due_date", description="Due date (YYYY-MM-DD)"), + _p("priority", description="Priority (Low, Medium, High, Urgent)"), + _p("status", description="Status"), + _p("start_date", description="Start date (YYYY-MM-DD)"), + ], + }, + { + "tool_name": "crm_get_contacts", + "description": "List/search CRM contacts linked to deals.", + "function_path": "huf.ai.tools.crm.handle_get_contacts", + "category": "CRM Tools", + "parameters": [ + _p("search", description="Search across full_name, email, mobile_no"), + _p("deal", description="Filter by parent deal ID"), + _p("limit", type="integer", description="Max contacts to fetch (default 20)"), + _p("offset", type="integer", description="Offset for pagination (default 0)"), + ], + }, +] + +# --------------------------------------------------------------------------- +# Helpdesk Tools +# --------------------------------------------------------------------------- + +HELPDESK_TOOLS = [ + { + "tool_name": "helpdesk_get_tickets", + "description": "List helpdesk tickets with optional filters (status, priority, assigned_to, team, search).", + "function_path": "huf.ai.tools.helpdesk.handle_get_tickets", + "category": "Helpdesk Tools", + "parameters": [ + _p("status", description="Filter by ticket status"), + _p("priority", description="Filter by priority"), + _p("assigned_to", description="Filter by assigned agent user ID"), + _p("team", description="Filter by team (agent_group)"), + _p("ticket_type", description="Filter by ticket type"), + _p("search", description="Search across subject and raised_by"), + _p("limit", type="integer", description="Max tickets to fetch (default 20)"), + _p("offset", type="integer", description="Offset for pagination (default 0)"), + ], + }, + { + "tool_name": "helpdesk_get_ticket", + "description": "Get a single helpdesk ticket by ID with comments.", + "function_path": "huf.ai.tools.helpdesk.handle_get_ticket", + "category": "Helpdesk Tools", + "parameters": [ + _p("name", required=True, description="Ticket ID/name"), + ], + }, + { + "tool_name": "helpdesk_create_ticket", + "description": "Create a new helpdesk support ticket.", + "function_path": "huf.ai.tools.helpdesk.handle_create_ticket", + "category": "Helpdesk Tools", + "parameters": [ + _p("subject", required=True, description="Ticket subject"), + _p("description", description="Ticket description"), + _p("raised_by", description="Email of the requester"), + _p("customer", description="Customer ID (HD Customer)"), + _p("contact", description="Contact ID (Contact)"), + _p("priority", description="Priority"), + _p("ticket_type", description="Ticket type"), + _p("team", description="Team to assign (agent_group)"), + ], + }, + { + "tool_name": "helpdesk_update_ticket", + "description": "Update an existing helpdesk ticket (status, priority, assigned_to, team).", + "function_path": "huf.ai.tools.helpdesk.handle_update_ticket", + "category": "Helpdesk Tools", + "parameters": [ + _p("name", required=True, description="Ticket ID/name"), + _p("status", description="Ticket status"), + _p("priority", description="Priority"), + _p("assigned_to", description="Agent user ID to assign"), + _p("team", description="Team (agent_group)"), + _p("ticket_type", description="Ticket type"), + _p("subject", description="Subject"), + _p("description", description="Description"), + _p("resolution_details", description="Resolution details"), + _p("contact", description="Contact"), + _p("customer", description="Customer"), + ], + }, + { + "tool_name": "helpdesk_add_comment", + "description": "Add a comment/reply to a helpdesk ticket.", + "function_path": "huf.ai.tools.helpdesk.handle_add_comment", + "category": "Helpdesk Tools", + "parameters": [ + _p("ticket_id", required=True, description="Ticket ID"), + _p("content", required=True, description="Comment content"), + _p("commented_by", description="User ID (defaults to current user)"), + ], + }, + { + "tool_name": "helpdesk_get_agents", + "description": "List helpdesk agents.", + "function_path": "huf.ai.tools.helpdesk.handle_get_agents", + "category": "Helpdesk Tools", + "parameters": [ + _p("is_active", type="Check", description="Filter by active status"), + _p("search", description="Search by agent name"), + _p("limit", type="integer", description="Max agents to fetch (default 20)"), + _p("offset", type="integer", description="Offset for pagination (default 0)"), + ], + }, + { + "tool_name": "helpdesk_get_teams", + "description": "List helpdesk teams.", + "function_path": "huf.ai.tools.helpdesk.handle_get_teams", + "category": "Helpdesk Tools", + "parameters": [ + _p("search", description="Search by team name"), + _p("limit", type="integer", description="Max teams to fetch (default 20)"), + _p("offset", type="integer", description="Offset for pagination (default 0)"), + ], + }, + { + "tool_name": "helpdesk_assign_ticket", + "description": "Assign a helpdesk ticket to an agent.", + "function_path": "huf.ai.tools.helpdesk.handle_assign_ticket", + "category": "Helpdesk Tools", + "parameters": [ + _p("ticket_id", required=True, description="Ticket ID"), + _p("agent_id", description="Agent user ID (defaults to current user)"), + ], + }, +] + +# --------------------------------------------------------------------------- +# Raven Tools +# --------------------------------------------------------------------------- + +RAVEN_TOOLS = [ + { + "tool_name": "raven_send_message", + "description": "Send a message to a Raven channel by channel_id or channel_name.", + "function_path": "huf.ai.tools.raven.handle_send_message", + "category": "Raven Tools", + "parameters": [ + _p("channel_id", description="Raven Channel ID"), + _p("channel_name", description="Raven Channel name (alternative to channel_id)"), + _p("text", required=True, description="Message text"), + _p("message_type", description="Message type: Text, Image, File, Poll, System (default Text)"), + _p("is_reply", type="Check", description="Whether this is a reply"), + _p("linked_message", description="Message ID being replied to"), + ], + }, + { + "tool_name": "raven_get_messages", + "description": "Get recent messages from a Raven channel with pagination.", + "function_path": "huf.ai.tools.raven.handle_get_messages", + "category": "Raven Tools", + "parameters": [ + _p("channel_id", description="Raven Channel ID"), + _p("channel_name", description="Raven Channel name (alternative to channel_id)"), + _p("limit", type="integer", description="Max messages to fetch (default 20)"), + _p("before_message_id", description="Fetch messages before this message ID"), + ], + }, + { + "tool_name": "raven_list_channels", + "description": "List all Raven channels. Optionally filter by type (public, private, dm).", + "function_path": "huf.ai.tools.raven.handle_list_channels", + "category": "Raven Tools", + "parameters": [ + _p("channel_type", description="Filter by type: public, private, dm"), + _p("limit", type="integer", description="Max channels to fetch (default 50)"), + _p("offset", type="integer", description="Offset for pagination (default 0)"), + ], + }, + { + "tool_name": "raven_get_channel_members", + "description": "Get members of a Raven channel.", + "function_path": "huf.ai.tools.raven.handle_get_channel_members", + "category": "Raven Tools", + "parameters": [ + _p("channel_id", description="Raven Channel ID"), + _p("channel_name", description="Raven Channel name (alternative to channel_id)"), + _p("limit", type="integer", description="Max members to fetch (default 100)"), + _p("offset", type="integer", description="Offset for pagination (default 0)"), + ], + }, + { + "tool_name": "raven_create_channel", + "description": "Create a new Raven channel and optionally add members.", + "function_path": "huf.ai.tools.raven.handle_create_channel", + "category": "Raven Tools", + "parameters": [ + _p("channel_name", required=True, description="Name of the new channel"), + _p("channel_type", description="Type: Public, Private, Open (default Public)"), + _p("channel_description", description="Channel description"), + _p("workspace", description="Workspace ID"), + _p("members", description="List of Raven User IDs to add as members"), + ], + }, + { + "tool_name": "raven_search_messages", + "description": "Search messages across Raven channels or within a specific channel.", + "function_path": "huf.ai.tools.raven.handle_search_messages", + "category": "Raven Tools", + "parameters": [ + _p("query", required=True, description="Search text"), + _p("channel_id", description="Restrict search to a specific channel ID"), + _p("channel_name", description="Restrict search to a specific channel name"), + _p("limit", type="integer", description="Max results (default 20)"), + _p("offset", type="integer", description="Offset for pagination (default 0)"), + ], + }, +] + # --------------------------------------------------------------------------- # Master list: every tool grouped for easy iteration @@ -228,4 +584,7 @@ def _p(name, type="string", required=False, description=""): + DISCORD_TOOLS + TELEGRAM_TOOLS + GITHUB_TOOLS + + CRM_TOOLS + + HELPDESK_TOOLS + + RAVEN_TOOLS ) diff --git a/huf/ai/tools/crm.py b/huf/ai/tools/crm.py new file mode 100644 index 00000000..f7eb1bfc --- /dev/null +++ b/huf/ai/tools/crm.py @@ -0,0 +1,503 @@ +""" +CRM integration tools for Frappe CRM (FCRM). +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _crm_installed(): + try: + return "crm" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}) + + +# --------------------------------------------------------------------------- +# Leads +# --------------------------------------------------------------------------- + +def handle_get_leads(**kwargs) -> str: + """List leads with optional filters (status, assigned_to, search query).""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + filters = {} + status = kwargs.get("status") + lead_owner = kwargs.get("assigned_to") or kwargs.get("lead_owner") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + + if status: + filters["status"] = status + if lead_owner: + filters["lead_owner"] = lead_owner + + or_filters = [] + if search: + or_filters = [ + ["CRM Lead", "lead_name", "like", f"%{search}%"], + ["CRM Lead", "email", "like", f"%{search}%"], + ["CRM Lead", "mobile_no", "like", f"%{search}%"], + ["CRM Lead", "organization", "like", f"%{search}%"], + ] + + fields = [ + "name", + "lead_name", + "first_name", + "last_name", + "email", + "mobile_no", + "status", + "lead_owner", + "organization", + "source", + "modified", + ] + + leads = frappe.get_all( + "CRM Lead", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(leads), "results": leads}) + except Exception as e: + frappe.log_error(f"CRM Get Leads Error: {e}", "CRM Tool") + return _error(str(e)) + + +def handle_get_lead(**kwargs) -> str: + """Get a single lead by name/ID with all fields.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + name = kwargs.get("name") or kwargs.get("lead_id") + if not name: + return _error("name or lead_id is required") + + if not frappe.db.exists("CRM Lead", name): + return _error(f"Lead {name} not found") + + doc = frappe.get_doc("CRM Lead", name) + return json.dumps({"success": True, "results": doc.as_dict()}) + except Exception as e: + frappe.log_error(f"CRM Get Lead Error: {e}", "CRM Tool") + return _error(str(e)) + + +def handle_create_lead(**kwargs) -> str: + """Create a new lead.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + first_name = kwargs.get("first_name") + if not first_name: + return _error("first_name is required") + + last_name = kwargs.get("last_name", "") + lead_name = kwargs.get("lead_name") or f"{first_name} {last_name}".strip() + + doc = frappe.new_doc("CRM Lead") + doc.first_name = first_name + doc.last_name = last_name + doc.lead_name = lead_name + doc.email = kwargs.get("email", "") + doc.mobile_no = kwargs.get("mobile_no", "") + doc.lead_owner = kwargs.get("lead_owner", "") + doc.source = kwargs.get("source", "") + doc.organization = kwargs.get("organization", "") + doc.status = kwargs.get("status", "New") + doc.insert(ignore_permissions=True) + + notes = kwargs.get("notes", "") + if notes: + note = frappe.new_doc("FCRM Note") + note.title = kwargs.get("note_title", "Note") + note.content = notes + note.reference_doctype = "CRM Lead" + note.reference_docname = doc.name + note.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}}) + except Exception as e: + frappe.log_error(f"CRM Create Lead Error: {e}", "CRM Tool") + return _error(str(e)) + + +def handle_update_lead(**kwargs) -> str: + """Update lead fields.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + name = kwargs.get("name") or kwargs.get("lead_id") + if not name: + return _error("name or lead_id is required") + if not frappe.db.exists("CRM Lead", name): + return _error(f"Lead {name} not found") + + doc = frappe.get_doc("CRM Lead", name) + updatable = [ + "first_name", + "last_name", + "lead_name", + "email", + "mobile_no", + "phone", + "status", + "lead_owner", + "source", + "organization", + "website", + "territory", + "industry", + "job_title", + "annual_revenue", + "no_of_employees", + "gender", + "salutation", + "converted", + ] + for field in updatable: + if field in kwargs: + setattr(doc, field, kwargs[field]) + + doc.save(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}}) + except Exception as e: + frappe.log_error(f"CRM Update Lead Error: {e}", "CRM Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Deals +# --------------------------------------------------------------------------- + +def handle_get_deals(**kwargs) -> str: + """List deals with optional filters.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + filters = {} + status = kwargs.get("status") + deal_owner = kwargs.get("deal_owner") + organization = kwargs.get("organization") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + + if status: + filters["status"] = status + if deal_owner: + filters["deal_owner"] = deal_owner + if organization: + filters["organization"] = organization + + or_filters = [] + if search: + or_filters = [ + ["CRM Deal", "organization", "like", f"%{search}%"], + ["CRM Deal", "lead_name", "like", f"%{search}%"], + ["CRM Deal", "email", "like", f"%{search}%"], + ] + + fields = [ + "name", + "organization", + "status", + "deal_owner", + "deal_value", + "expected_deal_value", + "probability", + "expected_closure_date", + "closed_date", + "modified", + ] + + deals = frappe.get_all( + "CRM Deal", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(deals), "results": deals}) + except Exception as e: + frappe.log_error(f"CRM Get Deals Error: {e}", "CRM Tool") + return _error(str(e)) + + +def handle_create_deal(**kwargs) -> str: + """Create a deal from a lead or standalone.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + doc = frappe.new_doc("CRM Deal") + + lead = kwargs.get("lead") + if lead: + if not frappe.db.exists("CRM Lead", lead): + return _error(f"Lead {lead} not found") + doc.lead = lead + lead_doc = frappe.get_doc("CRM Lead", lead) + doc.lead_name = lead_doc.lead_name + doc.organization_name = lead_doc.organization + # Try to link existing CRM Organization; leave blank if not found + if lead_doc.organization and frappe.db.exists("CRM Organization", lead_doc.organization): + doc.organization = lead_doc.organization + doc.email = lead_doc.email + doc.mobile_no = lead_doc.mobile_no + doc.phone = lead_doc.phone + doc.first_name = lead_doc.first_name + doc.last_name = lead_doc.last_name + doc.salutation = lead_doc.salutation + doc.gender = lead_doc.gender + doc.source = lead_doc.source + doc.territory = lead_doc.territory + doc.industry = lead_doc.industry + doc.annual_revenue = lead_doc.annual_revenue + doc.no_of_employees = lead_doc.no_of_employees + doc.job_title = lead_doc.job_title + doc.website = lead_doc.website + + # Override with explicit kwargs if provided + if "organization_name" in kwargs: + doc.organization_name = kwargs["organization_name"] + if "organization" in kwargs: + org = kwargs["organization"] + if org and frappe.db.exists("CRM Organization", org): + doc.organization = org + elif org and not doc.organization_name: + doc.organization_name = org + + doc.status = kwargs.get("status", "Qualification") + doc.deal_owner = kwargs.get("deal_owner", "") + doc.deal_value = kwargs.get("deal_value", 0) + doc.expected_deal_value = kwargs.get("expected_deal_value", 0) + doc.probability = kwargs.get("probability", 0) + doc.expected_closure_date = kwargs.get("expected_closure_date", "") + doc.closed_date = kwargs.get("closed_date", "") + doc.email = kwargs.get("email", doc.email or "") + doc.mobile_no = kwargs.get("mobile_no", doc.mobile_no or "") + doc.first_name = kwargs.get("first_name", doc.first_name or "") + doc.last_name = kwargs.get("last_name", doc.last_name or "") + + doc.insert(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name, "organization": doc.organization_name or doc.organization}}) + except Exception as e: + frappe.log_error(f"CRM Create Deal Error: {e}", "CRM Tool") + return _error(str(e)) + + +def handle_update_deal(**kwargs) -> str: + """Update deal fields.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + name = kwargs.get("name") or kwargs.get("deal_id") + if not name: + return _error("name or deal_id is required") + if not frappe.db.exists("CRM Deal", name): + return _error(f"Deal {name} not found") + + doc = frappe.get_doc("CRM Deal", name) + updatable = [ + "organization", + "status", + "deal_owner", + "deal_value", + "expected_deal_value", + "probability", + "expected_closure_date", + "closed_date", + "next_step", + "email", + "mobile_no", + "phone", + "website", + "territory", + "industry", + "annual_revenue", + "lost_reason", + "lost_notes", + ] + for field in updatable: + if field in kwargs: + setattr(doc, field, kwargs[field]) + + doc.save(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name, "organization": doc.organization}}) + except Exception as e: + frappe.log_error(f"CRM Update Deal Error: {e}", "CRM Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Notes & Tasks +# --------------------------------------------------------------------------- + +def handle_add_note(**kwargs) -> str: + """Add a note to a lead or deal.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + doctype = kwargs.get("doctype") + docname = kwargs.get("docname") + content = kwargs.get("content") + if not all([doctype, docname, content]): + return _error("doctype, docname, and content are required") + + if not frappe.db.exists(doctype, docname): + return _error(f"Document {doctype} {docname} not found") + + note = frappe.new_doc("FCRM Note") + note.title = kwargs.get("title", "Note") + note.content = content + note.reference_doctype = doctype + note.reference_docname = docname + note.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": note.name, "title": note.title}}) + except Exception as e: + frappe.log_error(f"CRM Add Note Error: {e}", "CRM Tool") + return _error(str(e)) + + +def handle_add_task(**kwargs) -> str: + """Create a task linked to a lead or deal.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + title = kwargs.get("title") + reference_doctype = kwargs.get("reference_doctype") + reference_docname = kwargs.get("reference_docname") + if not all([title, reference_doctype, reference_docname]): + return _error("title, reference_doctype, and reference_docname are required") + + if not frappe.db.exists(reference_doctype, reference_docname): + return _error(f"Document {reference_doctype} {reference_docname} not found") + + task = frappe.new_doc("CRM Task") + task.title = title + task.reference_doctype = reference_doctype + task.reference_docname = reference_docname + task.assigned_to = kwargs.get("assigned_to", "") + task.status = kwargs.get("status", "Todo") + task.priority = kwargs.get("priority", "Medium") + task.due_date = kwargs.get("due_date", "") + task.description = kwargs.get("description", "") + task.start_date = kwargs.get("start_date", "") + task.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": task.name, "title": task.title}}) + except Exception as e: + frappe.log_error(f"CRM Add Task Error: {e}", "CRM Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Contacts +# --------------------------------------------------------------------------- + +def handle_get_contacts(**kwargs) -> str: + """List/search contacts. Optionally filter by deal to return CRM Contacts child rows.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + search = kwargs.get("search") + deal = kwargs.get("deal") + limit = int(kwargs.get("limit", 20)) + offset = int(kwargs.get("offset", 0)) + + if deal: + if not frappe.db.exists("CRM Deal", deal): + return _error(f"Deal {deal} not found") + + filters = {"parenttype": "CRM Deal", "parent": deal} + or_filters = [] + if search: + or_filters = [ + ["CRM Contacts", "full_name", "like", f"%{search}%"], + ["CRM Contacts", "email", "like", f"%{search}%"], + ["CRM Contacts", "mobile_no", "like", f"%{search}%"], + ] + + fields = [ + "name", + "contact", + "full_name", + "email", + "mobile_no", + "phone", + "gender", + "is_primary", + "modified", + ] + + contacts = frappe.get_all( + "CRM Contacts", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + limit_start=offset, + order_by="modified desc", + ) + else: + filters = {} + or_filters = [] + if search: + or_filters = [ + ["Contact", "first_name", "like", f"%{search}%"], + ["Contact", "last_name", "like", f"%{search}%"], + ["Contact", "email_id", "like", f"%{search}%"], + ["Contact", "mobile_no", "like", f"%{search}%"], + ] + + fields = [ + "name", + "first_name", + "last_name", + "email_id", + "mobile_no", + "phone", + "company_name", + "modified", + ] + + contacts = frappe.get_all( + "Contact", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + limit_start=offset, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(contacts), "results": contacts}) + except frappe.DoesNotExistError: + return _error("Contact DocType does not exist on this site.") + except Exception as e: + frappe.log_error(f"CRM Get Contacts Error: {e}", "CRM Tool") + return _error(str(e)) diff --git a/huf/ai/tools/helpdesk.py b/huf/ai/tools/helpdesk.py new file mode 100644 index 00000000..2683022c --- /dev/null +++ b/huf/ai/tools/helpdesk.py @@ -0,0 +1,285 @@ +""" +Helpdesk integration tools for Frappe Helpdesk. +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _helpdesk_installed(): + try: + return "helpdesk" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}) + + +# --------------------------------------------------------------------------- +# Tickets +# --------------------------------------------------------------------------- + +def handle_get_tickets(**kwargs) -> str: + """List tickets with filters (status, priority, assigned_to, team, search).""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + filters = {} + status = kwargs.get("status") + priority = kwargs.get("priority") + assigned_to = kwargs.get("assigned_to") + team = kwargs.get("team") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + + if status: + filters["status"] = status + if priority: + filters["priority"] = priority + if team: + filters["agent_group"] = team + if assigned_to: + # _assign stores JSON list of assigned users + filters["_assign"] = ["like", f"%{assigned_to}%"] + + or_filters = [] + if search: + or_filters = [ + ["HD Ticket", "subject", "like", f"%{search}%"], + ["HD Ticket", "description", "like", f"%{search}%"], + ["HD Ticket", "raised_by", "like", f"%{search}%"], + ] + + fields = [ + "name", + "subject", + "raised_by", + "status", + "priority", + "agent_group", + "ticket_type", + "customer", + "contact", + "modified", + ] + + tickets = frappe.get_all( + "HD Ticket", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(tickets), "results": tickets}) + except Exception as e: + frappe.log_error(f"Helpdesk Get Tickets Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def handle_get_ticket(**kwargs) -> str: + """Get single ticket details with comments.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + ticket_id = kwargs.get("ticket_id") or kwargs.get("name") + if not ticket_id: + return _error("ticket_id or name is required") + if not frappe.db.exists("HD Ticket", ticket_id): + return _error(f"Ticket {ticket_id} not found") + + doc = frappe.get_doc("HD Ticket", ticket_id) + comments = frappe.get_all( + "HD Ticket Comment", + filters={"reference_ticket": ticket_id}, + fields=["name", "content", "commented_by", "creation", "is_pinned"], + order_by="creation desc", + limit=50, + ) + + result = doc.as_dict() + result["comments"] = comments + return json.dumps({"success": True, "results": result}) + except Exception as e: + frappe.log_error(f"Helpdesk Get Ticket Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def handle_create_ticket(**kwargs) -> str: + """Create a support ticket.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + subject = kwargs.get("subject") + if not subject: + return _error("subject is required") + + doc = frappe.new_doc("HD Ticket") + doc.subject = subject + doc.description = kwargs.get("description", "") + doc.customer = kwargs.get("customer", "") + doc.priority = kwargs.get("priority", "") + doc.ticket_type = kwargs.get("ticket_type", "") + doc.agent_group = kwargs.get("team", "") + doc.raised_by = kwargs.get("raised_by", frappe.session.user) + doc.contact = kwargs.get("contact", "") + doc.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": doc.name, "subject": doc.subject}}) + except Exception as e: + frappe.log_error(f"Helpdesk Create Ticket Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def handle_update_ticket(**kwargs) -> str: + """Update ticket (status, priority, assigned_to, team).""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + ticket_id = kwargs.get("ticket_id") or kwargs.get("name") + if not ticket_id: + return _error("ticket_id or name is required") + if not frappe.db.exists("HD Ticket", ticket_id): + return _error(f"Ticket {ticket_id} not found") + + doc = frappe.get_doc("HD Ticket", ticket_id) + + if "status" in kwargs: + doc.status = kwargs["status"] + if "priority" in kwargs: + doc.priority = kwargs["priority"] + if "team" in kwargs: + doc.agent_group = kwargs["team"] + if "description" in kwargs: + doc.description = kwargs["description"] + if "resolution_details" in kwargs: + doc.resolution_details = kwargs["resolution_details"] + + doc.save(ignore_permissions=True) + + assigned_to = kwargs.get("assigned_to") + if assigned_to: + if not frappe.db.exists("HD Agent", assigned_to): + return _error(f"Agent {assigned_to} not found") + doc.assign_agent(assigned_to) + + return json.dumps({"success": True, "results": {"name": doc.name, "subject": doc.subject}}) + except Exception as e: + frappe.log_error(f"Helpdesk Update Ticket Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def handle_add_comment(**kwargs) -> str: + """Add a comment/reply to a ticket.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + ticket_id = kwargs.get("ticket_id") + content = kwargs.get("content") + if not all([ticket_id, content]): + return _error("ticket_id and content are required") + if not frappe.db.exists("HD Ticket", ticket_id): + return _error(f"Ticket {ticket_id} not found") + + comment = frappe.new_doc("HD Ticket Comment") + comment.reference_ticket = ticket_id + comment.content = content + comment.commented_by = kwargs.get("commented_by", frappe.session.user) + comment.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": comment.name}}) + except Exception as e: + frappe.log_error(f"Helpdesk Add Comment Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Agents & Teams +# --------------------------------------------------------------------------- + +def handle_get_agents(**kwargs) -> str: + """List helpdesk agents.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + limit = int(kwargs.get("limit", 50)) + offset = int(kwargs.get("offset", 0)) + filters = {} + is_active = kwargs.get("is_active") + if is_active is not None: + filters["is_active"] = int(is_active) + + search = kwargs.get("search") + or_filters = [] + if search: + or_filters = [ + ["HD Agent", "agent_name", "like", f"%{search}%"], + ["HD Agent", "user", "like", f"%{search}%"], + ] + + agents = frappe.get_all( + "HD Agent", + fields=["name", "agent_name", "user", "is_active"], + filters=filters, + or_filters=or_filters or None, + limit=limit, + limit_start=offset, + order_by="modified desc", + ) + return json.dumps({"success": True, "count": len(agents), "results": agents}) + except Exception as e: + frappe.log_error(f"Helpdesk Get Agents Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def handle_get_teams(**kwargs) -> str: + """List helpdesk teams.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + limit = int(kwargs.get("limit", 50)) + teams = frappe.get_all( + "HD Team", + fields=["name", "team_name", "ignore_restrictions"], + limit=limit, + order_by="modified desc", + ) + return json.dumps({"success": True, "count": len(teams), "results": teams}) + except Exception as e: + frappe.log_error(f"Helpdesk Get Teams Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def handle_assign_ticket(**kwargs) -> str: + """Assign ticket to an agent.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + ticket_id = kwargs.get("ticket_id") + agent_id = kwargs.get("agent_id") or frappe.session.user + if not ticket_id: + return _error("ticket_id is required") + if not frappe.db.exists("HD Ticket", ticket_id): + return _error(f"Ticket {ticket_id} not found") + if not frappe.db.exists("HD Agent", agent_id): + return _error(f"Agent {agent_id} not found") + + doc = frappe.get_doc("HD Ticket", ticket_id) + doc.assign_agent(agent_id) + + return json.dumps({"success": True, "results": {"name": doc.name, "assigned_to": agent_id}}) + except Exception as e: + frappe.log_error(f"Helpdesk Assign Ticket Error: {e}", "Helpdesk Tool") + return _error(str(e)) diff --git a/huf/ai/tools/raven.py b/huf/ai/tools/raven.py new file mode 100644 index 00000000..3dc4e354 --- /dev/null +++ b/huf/ai/tools/raven.py @@ -0,0 +1,274 @@ +""" +Raven integration tools for Frappe Raven. +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _raven_installed(): + try: + return "raven" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}) + + +def _resolve_channel_id(channel_id=None, channel_name=None): + if channel_id and frappe.db.exists("Raven Channel", channel_id): + return channel_id + if channel_name: + cid = frappe.db.get_value("Raven Channel", {"channel_name": channel_name}, "name") + if cid: + return cid + return None + + +# --------------------------------------------------------------------------- +# Messages +# --------------------------------------------------------------------------- + +def handle_send_message(**kwargs) -> str: + """Send a message to a Raven channel.""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + channel_id = _resolve_channel_id( + kwargs.get("channel_id"), kwargs.get("channel_name") + ) + text = kwargs.get("text") + if not channel_id: + return _error("channel_id or channel_name is required and must exist") + if not text: + return _error("text is required") + + doc = frappe.new_doc("Raven Message") + doc.channel_id = channel_id + doc.text = text + doc.message_type = kwargs.get("message_type", "Text") + doc.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": doc.name, "channel_id": channel_id}}) + except Exception as e: + frappe.log_error(f"Raven Send Message Error: {e}", "Raven Tool") + return _error(str(e)) + + +def handle_get_messages(**kwargs) -> str: + """Get recent messages from a channel.""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + channel_id = _resolve_channel_id( + kwargs.get("channel_id"), kwargs.get("channel_name") + ) + if not channel_id: + return _error("channel_id or channel_name is required and must exist") + + limit = int(kwargs.get("limit", 50)) + before_message_id = kwargs.get("before_message_id") + + filters = {"channel_id": channel_id} + if before_message_id: + creation = frappe.db.get_value("Raven Message", before_message_id, "creation") + if creation: + filters["creation"] = ["<", creation] + + messages = frappe.get_all( + "Raven Message", + fields=[ + "name", + "text", + "content", + "message_type", + "owner", + "creation", + "channel_id", + "file", + "is_reply", + "linked_message", + "is_edited", + "is_forwarded", + "is_thread", + ], + filters=filters, + limit=limit, + order_by="creation desc", + ) + + return json.dumps({"success": True, "count": len(messages), "results": messages}) + except Exception as e: + frappe.log_error(f"Raven Get Messages Error: {e}", "Raven Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Channels +# --------------------------------------------------------------------------- + +def handle_list_channels(**kwargs) -> str: + """List all channels (optionally filter by type: public/private/open).""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + filters = {"is_archived": 0} + channel_type = kwargs.get("channel_type") or kwargs.get("type") + if channel_type: + filters["type"] = channel_type.capitalize() + + limit = int(kwargs.get("limit", 50)) + + channels = frappe.get_all( + "Raven Channel", + fields=[ + "name", + "channel_name", + "type", + "channel_description", + "is_direct_message", + "is_archived", + "workspace", + "creation", + "modified", + ], + filters=filters, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(channels), "results": channels}) + except Exception as e: + frappe.log_error(f"Raven List Channels Error: {e}", "Raven Tool") + return _error(str(e)) + + +def handle_get_channel_members(**kwargs) -> str: + """Get members of a channel.""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + channel_id = _resolve_channel_id( + kwargs.get("channel_id"), kwargs.get("channel_name") + ) + if not channel_id: + return _error("channel_id or channel_name is required and must exist") + + members = frappe.get_all( + "Raven Channel Member", + filters={"channel_id": channel_id}, + fields=["name", "user_id", "is_admin", "last_visit", "allow_notifications"], + order_by="creation asc", + ) + + return json.dumps({"success": True, "count": len(members), "results": members}) + except Exception as e: + frappe.log_error(f"Raven Get Channel Members Error: {e}", "Raven Tool") + return _error(str(e)) + + +def handle_create_channel(**kwargs) -> str: + """Create a new channel.""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + channel_name = kwargs.get("channel_name") + channel_type = kwargs.get("type", "Public") + if not channel_name: + return _error("channel_name is required") + + valid_types = {"Private", "Public", "Open"} + ctype = channel_type.capitalize() + if ctype not in valid_types: + return _error("type must be one of: Private, Public, Open") + + workspace = kwargs.get("workspace") + if not workspace: + workspaces = frappe.get_all("Raven Workspace", fields=["name"], limit=1) + if workspaces: + workspace = workspaces[0].name + + if not workspace: + return _error("No Raven workspace found. Please specify workspace.") + + doc = frappe.new_doc("Raven Channel") + doc.channel_name = channel_name + doc.type = ctype + doc.workspace = workspace + doc.channel_description = kwargs.get("channel_description", "") + doc.insert(ignore_permissions=True) + + members = kwargs.get("members", []) + if isinstance(members, str): + try: + members = json.loads(members) + except Exception: + members = [] + + for user_id in members: + if not frappe.db.exists("Raven Channel Member", {"channel_id": doc.name, "user_id": user_id}): + member = frappe.new_doc("Raven Channel Member") + member.channel_id = doc.name + member.user_id = user_id + member.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": doc.name, "channel_name": doc.channel_name}}) + except Exception as e: + frappe.log_error(f"Raven Create Channel Error: {e}", "Raven Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Search +# --------------------------------------------------------------------------- + +def handle_search_messages(**kwargs) -> str: + """Search messages across channels or within a specific channel.""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + query = kwargs.get("query") + if not query: + return _error("query is required") + + channel_id = _resolve_channel_id( + kwargs.get("channel_id"), kwargs.get("channel_name") + ) + limit = int(kwargs.get("limit", 50)) + + filters = {"text": ["like", f"%{query}%"]} + if channel_id: + filters["channel_id"] = channel_id + + messages = frappe.get_all( + "Raven Message", + fields=[ + "name", + "text", + "content", + "message_type", + "owner", + "creation", + "channel_id", + "file", + "is_reply", + "linked_message", + ], + filters=filters, + limit=limit, + order_by="creation desc", + ) + + return json.dumps({"success": True, "count": len(messages), "results": messages}) + except Exception as e: + frappe.log_error(f"Raven Search Messages Error: {e}", "Raven Tool") + return _error(str(e)) diff --git a/huf/install.py b/huf/install.py index 605caf86..fc6ccae4 100644 --- a/huf/install.py +++ b/huf/install.py @@ -117,6 +117,7 @@ def after_migrate(): remove_deprecated_gemini_audio_tools() create_ocr_document_tool() create_flow_tools() + sync_tool_types() from huf.ai.tool_registry import sync_discovered_tools result = sync_discovered_tools() # Full scan (apps_to_scan=None) frappe.log_error( From 419f056f7eb45bcd507a9117968ad052da276ac5 Mon Sep 17 00:00:00 2001 From: esafwan Date: Fri, 29 May 2026 01:47:32 +0400 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20add=20ERPNext=20tools=20=E2=80=94?= =?UTF-8?q?=20financials,=20CRM,=20inventory,=20BOM,=20and=20reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 5 new tool modules covering broad ERPNext business automation: erpnext.py (14 tools — ERPNext Tools category): Sales/Purchase Invoices, Payment Entry, Quotation, Customer CRUD, Account Ledger (GL queries), Journal Entry creation, RFQ listing erpnext_crm.py (7 tools — ERPNext CRM Tools category): ERPNext-native CRM: Lead and Opportunity lifecycle (get/create/update). Differentiated from standalone Frappe CRM (CRM Lead / CRM Deal) by doctype names, tool prefix (erpnext_crm_*), and category label. erpnext_inventory.py (12 tools — ERPNext Inventory category): Item/Item Price lookup, BOM get/create, real-time Stock Balance (SLE dedup), Stock Movements, Stock Entries, Warehouses, Delivery Notes, Purchase Receipts erpnext_reports.py (14 tools — ERPNext Reports category): Balance Sheet, P&L, Trial Balance, General Ledger, Accounts Receivable/Payable, Bank Reconciliation, Sales Register, Sales Order Analysis, Customer Acquisition, Stock Balance, Stock Ledger, Item-wise Sales, Gross Profit All via frappe.desk.query_report.run — read-only, no side effects. Registry updated to 89 tools across 9 categories. Frappe CRM tools renamed category to "Frappe CRM Tools" to distinguish from ERPNext CRM. All files AST-validated clean. --- huf/ai/tools/_registry.py | 667 ++++++++++++++++++++++++++++- huf/ai/tools/erpnext.py | 683 ++++++++++++++++++++++++++++++ huf/ai/tools/erpnext_crm.py | 284 +++++++++++++ huf/ai/tools/erpnext_inventory.py | 554 ++++++++++++++++++++++++ huf/ai/tools/erpnext_reports.py | 417 ++++++++++++++++++ huf/ai/tools/helpdesk.py | 22 +- huf/ai/tools/raven.py | 13 +- 7 files changed, 2626 insertions(+), 14 deletions(-) create mode 100644 huf/ai/tools/erpnext.py create mode 100644 huf/ai/tools/erpnext_crm.py create mode 100644 huf/ai/tools/erpnext_inventory.py create mode 100644 huf/ai/tools/erpnext_reports.py diff --git a/huf/ai/tools/_registry.py b/huf/ai/tools/_registry.py index a8df9e7d..5e0011a3 100644 --- a/huf/ai/tools/_registry.py +++ b/huf/ai/tools/_registry.py @@ -226,7 +226,7 @@ def _p(name, type="string", required=False, description=""): "tool_name": "crm_get_leads", "description": "List CRM leads with optional filters (status, assigned_to, search). Returns paginated list with key fields.", "function_path": "huf.ai.tools.crm.handle_get_leads", - "category": "CRM Tools", + "category": "Frappe CRM Tools", "parameters": [ _p("status", description="Filter by lead status"), _p("assigned_to", description="Filter by lead owner email"), @@ -239,7 +239,7 @@ def _p(name, type="string", required=False, description=""): "tool_name": "crm_get_lead", "description": "Get a single CRM lead by name/ID with all fields.", "function_path": "huf.ai.tools.crm.handle_get_lead", - "category": "CRM Tools", + "category": "Frappe CRM Tools", "parameters": [ _p("name", required=True, description="Lead ID/name"), ], @@ -248,7 +248,7 @@ def _p(name, type="string", required=False, description=""): "tool_name": "crm_create_lead", "description": "Create a new CRM lead. Optionally provide notes to create a linked note.", "function_path": "huf.ai.tools.crm.handle_create_lead", - "category": "CRM Tools", + "category": "Frappe CRM Tools", "parameters": [ _p("first_name", required=True, description="First name of the lead"), _p("last_name", description="Last name of the lead"), @@ -264,7 +264,7 @@ def _p(name, type="string", required=False, description=""): "tool_name": "crm_update_lead", "description": "Update fields on an existing CRM lead.", "function_path": "huf.ai.tools.crm.handle_update_lead", - "category": "CRM Tools", + "category": "Frappe CRM Tools", "parameters": [ _p("name", required=True, description="Lead ID/name"), _p("lead_name", description="Computed lead name (auto-generated if not set)"), @@ -290,7 +290,7 @@ def _p(name, type="string", required=False, description=""): "tool_name": "crm_get_deals", "description": "List CRM deals with optional filters (status, assigned_to, organization, search).", "function_path": "huf.ai.tools.crm.handle_get_deals", - "category": "CRM Tools", + "category": "Frappe CRM Tools", "parameters": [ _p("status", description="Filter by deal status"), _p("assigned_to", description="Filter by deal owner email"), @@ -304,7 +304,7 @@ def _p(name, type="string", required=False, description=""): "tool_name": "crm_create_deal", "description": "Create a CRM deal from a lead or standalone. Provide either lead or organization.", "function_path": "huf.ai.tools.crm.handle_create_deal", - "category": "CRM Tools", + "category": "Frappe CRM Tools", "parameters": [ _p("lead", description="Lead ID to convert into a deal"), _p("organization", description="Organization name (required if lead is not provided)"), @@ -321,7 +321,7 @@ def _p(name, type="string", required=False, description=""): "tool_name": "crm_update_deal", "description": "Update an existing CRM deal (status, value, probability, close_date, etc.).", "function_path": "huf.ai.tools.crm.handle_update_deal", - "category": "CRM Tools", + "category": "Frappe CRM Tools", "parameters": [ _p("name", required=True, description="Deal ID/name"), _p("status", description="Deal status"), @@ -341,7 +341,7 @@ def _p(name, type="string", required=False, description=""): "tool_name": "crm_add_note", "description": "Add a note to a CRM lead or deal.", "function_path": "huf.ai.tools.crm.handle_add_note", - "category": "CRM Tools", + "category": "Frappe CRM Tools", "parameters": [ _p("doctype", required=True, description="Target DocType: CRM Lead or CRM Deal"), _p("docname", required=True, description="Target document name/ID"), @@ -353,7 +353,7 @@ def _p(name, type="string", required=False, description=""): "tool_name": "crm_add_task", "description": "Create a task linked to a CRM lead or deal.", "function_path": "huf.ai.tools.crm.handle_add_task", - "category": "CRM Tools", + "category": "Frappe CRM Tools", "parameters": [ _p("reference_doctype", required=True, description="CRM Lead or CRM Deal"), _p("reference_docname", required=True, description="Document name/ID"), @@ -370,7 +370,7 @@ def _p(name, type="string", required=False, description=""): "tool_name": "crm_get_contacts", "description": "List/search CRM contacts linked to deals.", "function_path": "huf.ai.tools.crm.handle_get_contacts", - "category": "CRM Tools", + "category": "Frappe CRM Tools", "parameters": [ _p("search", description="Search across full_name, email, mobile_no"), _p("deal", description="Filter by parent deal ID"), @@ -574,6 +574,649 @@ def _p(name, type="string", required=False, description=""): ] +# --------------------------------------------------------------------------- +# ERPNext Tools +# --------------------------------------------------------------------------- + +ERPNEXT_TOOLS = [ + { + "tool_name": "erpnext_get_sales_invoices", + "description": "List ERPNext sales invoices with optional filters (customer, status, date range). Returns key fields including grand_total and outstanding_amount.", + "function_path": "huf.ai.tools.erpnext.handle_get_sales_invoices", + "category": "ERPNext Tools", + "parameters": [ + _p("customer", description="Filter by customer ID"), + _p("status", description="Filter by status: Draft, Submitted, Paid, Overdue, Return, Cancelled"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("limit", type="integer", description="Max invoices to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_get_sales_invoice", + "description": "Get a single ERPNext sales invoice by name with full details including items child table.", + "function_path": "huf.ai.tools.erpnext.handle_get_sales_invoice", + "category": "ERPNext Tools", + "parameters": [ + _p("name", required=True, description="Sales Invoice ID/name"), + ], + }, + { + "tool_name": "erpnext_create_sales_invoice", + "description": "Create a draft ERPNext sales invoice. Provide customer and line items.", + "function_path": "huf.ai.tools.erpnext.handle_create_sales_invoice", + "category": "ERPNext Tools", + "parameters": [ + _p("customer", required=True, description="Customer ID"), + _p("company", description="Company name"), + _p("posting_date", description="Invoice date (YYYY-MM-DD)"), + _p("items", type="json", description="List of line items: [{item_code, qty, rate}]"), + ], + }, + { + "tool_name": "erpnext_get_purchase_invoices", + "description": "List ERPNext purchase invoices with optional filters (supplier, status, date range).", + "function_path": "huf.ai.tools.erpnext.handle_get_purchase_invoices", + "category": "ERPNext Tools", + "parameters": [ + _p("supplier", description="Filter by supplier ID"), + _p("status", description="Filter by status: Draft, Submitted, Paid, Overdue, Cancelled"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("limit", type="integer", description="Max invoices to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_get_purchase_invoice", + "description": "Get a single ERPNext purchase invoice by name with full details including items child table.", + "function_path": "huf.ai.tools.erpnext.handle_get_purchase_invoice", + "category": "ERPNext Tools", + "parameters": [ + _p("name", required=True, description="Purchase Invoice ID/name"), + ], + }, + { + "tool_name": "erpnext_get_payments", + "description": "List ERPNext payment entries with optional filters (party_type, party, payment_type, date range).", + "function_path": "huf.ai.tools.erpnext.handle_get_payments", + "category": "ERPNext Tools", + "parameters": [ + _p("party_type", description="Filter by party type: Customer, Supplier"), + _p("party", description="Filter by party name/ID"), + _p("payment_type", description="Filter by payment type: Receive, Pay, Internal Transfer"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("limit", type="integer", description="Max payments to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_create_payment", + "description": "Create a draft ERPNext payment entry. Optionally link to a Sales or Purchase Invoice.", + "function_path": "huf.ai.tools.erpnext.handle_create_payment", + "category": "ERPNext Tools", + "parameters": [ + _p("payment_type", required=True, description="Payment type: Receive, Pay, Internal Transfer"), + _p("party_type", required=True, description="Party type: Customer, Supplier"), + _p("party", required=True, description="Party name/ID"), + _p("company", description="Company name"), + _p("posting_date", description="Payment date (YYYY-MM-DD)"), + _p("paid_amount", required=True, description="Amount paid"), + _p("mode_of_payment", description="Mode of payment"), + _p("paid_from", description="Paid from account"), + _p("paid_to", description="Paid to account"), + _p("invoice_name", description="Sales or Purchase Invoice to link as reference"), + ], + }, + { + "tool_name": "erpnext_get_quotations", + "description": "List ERPNext quotations with optional filters (party_name, status, date range).", + "function_path": "huf.ai.tools.erpnext.handle_get_quotations", + "category": "ERPNext Tools", + "parameters": [ + _p("party_name", description="Filter by party name/ID"), + _p("status", description="Filter by quotation status"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("limit", type="integer", description="Max quotations to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_create_quotation", + "description": "Create a draft ERPNext quotation. Provide quotation_to, party_name, and line items.", + "function_path": "huf.ai.tools.erpnext.handle_create_quotation", + "category": "ERPNext Tools", + "parameters": [ + _p("quotation_to", required=True, description="Quotation to: Customer or Lead"), + _p("party_name", required=True, description="Customer or Lead ID"), + _p("company", description="Company name"), + _p("transaction_date", description="Quotation date (YYYY-MM-DD)"), + _p("valid_till", description="Valid until date (YYYY-MM-DD)"), + _p("items", type="json", description="List of line items: [{item_code, qty, rate}]"), + ], + }, + { + "tool_name": "erpnext_get_customers", + "description": "List/search ERPNext customers with optional filters (customer_group, territory, search query).", + "function_path": "huf.ai.tools.erpnext.handle_get_customers", + "category": "ERPNext Tools", + "parameters": [ + _p("search", description="Search across name, customer_name, or customer_group"), + _p("customer_group", description="Filter by customer group"), + _p("territory", description="Filter by territory"), + _p("limit", type="integer", description="Max customers to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_get_customer", + "description": "Get a single ERPNext customer by name with address and contact details.", + "function_path": "huf.ai.tools.erpnext.handle_get_customer", + "category": "ERPNext Tools", + "parameters": [ + _p("name", required=True, description="Customer ID/name"), + ], + }, + { + "tool_name": "erpnext_get_account_ledger", + "description": "Query GL entries for an account with running balance. GL Entry is read-only.", + "function_path": "huf.ai.tools.erpnext.handle_get_account_ledger", + "category": "ERPNext Tools", + "parameters": [ + _p("account", required=True, description="Account name/ID"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("party_type", description="Filter by party type"), + _p("party", description="Filter by party name/ID"), + _p("limit", type="integer", description="Max entries to fetch (default 50)"), + ], + }, + { + "tool_name": "erpnext_create_journal_entry", + "description": "Create a draft ERPNext journal entry. Total debit must equal total credit.", + "function_path": "huf.ai.tools.erpnext.handle_create_journal_entry", + "category": "ERPNext Tools", + "parameters": [ + _p("voucher_type", description="Voucher type: Journal Entry, Contra Entry, etc."), + _p("posting_date", required=True, description="Posting date (YYYY-MM-DD)"), + _p("company", required=True, description="Company name"), + _p("user_remark", description="User remark / narration"), + _p("accounts", type="json", required=True, description="Account lines: [{account, debit_in_account_currency, credit_in_account_currency, party_type, party, cost_center}]"), + ], + }, + { + "tool_name": "erpnext_get_rfqs", + "description": "List ERPNext requests for quotation with optional filters (status, date range).", + "function_path": "huf.ai.tools.erpnext.handle_get_rfqs", + "category": "ERPNext Tools", + "parameters": [ + _p("status", description="Filter by RFQ status"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("limit", type="integer", description="Max RFQs to fetch (default 20)"), + ], + }, +] + +# --------------------------------------------------------------------------- +# ERPNext CRM Tools +# --------------------------------------------------------------------------- + +ERPNEXT_CRM_TOOLS = [ + { + "tool_name": "erpnext_crm_get_leads", + "description": "List ERPNext leads with optional filters (status, lead_owner, search). Returns key fields.", + "function_path": "huf.ai.tools.erpnext_crm.handle_get_leads", + "category": "ERPNext CRM Tools", + "parameters": [ + _p("status", description="Filter by lead status"), + _p("lead_owner", description="Filter by lead owner email"), + _p("search", description="Search across lead_name, company_name, email_id"), + _p("limit", type="integer", description="Max leads to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_crm_get_lead", + "description": "Get a single ERPNext lead by name with all fields.", + "function_path": "huf.ai.tools.erpnext_crm.handle_get_lead", + "category": "ERPNext CRM Tools", + "parameters": [ + _p("name", required=True, description="Lead ID/name"), + ], + }, + { + "tool_name": "erpnext_crm_create_lead", + "description": "Create a new ERPNext lead.", + "function_path": "huf.ai.tools.erpnext_crm.handle_create_lead", + "category": "ERPNext CRM Tools", + "parameters": [ + _p("lead_name", required=True, description="Full name of the lead"), + _p("company_name", description="Company name"), + _p("email_id", description="Email address"), + _p("mobile_no", description="Mobile number"), + _p("phone", description="Phone number"), + _p("lead_owner", description="User email who owns this lead"), + _p("status", description="Lead status (default: Lead)"), + _p("type", description="Lead type"), + _p("market_segment", description="Market segment"), + _p("industry", description="Industry"), + _p("territory", description="Territory"), + _p("website", description="Website URL"), + ], + }, + { + "tool_name": "erpnext_crm_update_lead", + "description": "Update fields on an existing ERPNext lead.", + "function_path": "huf.ai.tools.erpnext_crm.handle_update_lead", + "category": "ERPNext CRM Tools", + "parameters": [ + _p("name", required=True, description="Lead ID/name"), + _p("status", description="Lead status"), + _p("lead_owner", description="Lead owner email"), + _p("email_id", description="Email address"), + _p("mobile_no", description="Mobile number"), + _p("territory", description="Territory"), + _p("qualification_status", description="Qualification status"), + ], + }, + { + "tool_name": "erpnext_crm_get_opportunities", + "description": "List ERPNext opportunities with optional filters (status, party_name, expected closing from date).", + "function_path": "huf.ai.tools.erpnext_crm.handle_get_opportunities", + "category": "ERPNext CRM Tools", + "parameters": [ + _p("status", description="Filter by opportunity status"), + _p("party_name", description="Filter by party name/ID"), + _p("from_date", description="Expected closing from date (YYYY-MM-DD)"), + _p("limit", type="integer", description="Max opportunities to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_crm_create_opportunity", + "description": "Create a new ERPNext opportunity linked to a Customer or Lead.", + "function_path": "huf.ai.tools.erpnext_crm.handle_create_opportunity", + "category": "ERPNext CRM Tools", + "parameters": [ + _p("opportunity_from", required=True, description="Opportunity from: Customer or Lead"), + _p("party_name", required=True, description="Customer or Lead ID"), + _p("title", description="Opportunity title"), + _p("opportunity_type", description="Opportunity type"), + _p("expected_closing", description="Expected closing date (YYYY-MM-DD)"), + _p("opportunity_amount", type="number", description="Opportunity amount"), + _p("sales_stage", description="Sales stage"), + _p("probability", type="number", description="Probability (0-100)"), + _p("currency", description="Currency code"), + ], + }, + { + "tool_name": "erpnext_crm_update_opportunity", + "description": "Update fields on an existing ERPNext opportunity.", + "function_path": "huf.ai.tools.erpnext_crm.handle_update_opportunity", + "category": "ERPNext CRM Tools", + "parameters": [ + _p("name", required=True, description="Opportunity ID/name"), + _p("status", description="Opportunity status"), + _p("opportunity_amount", type="number", description="Opportunity amount"), + _p("sales_stage", description="Sales stage"), + _p("probability", type="number", description="Probability"), + _p("expected_closing", description="Expected closing date (YYYY-MM-DD)"), + _p("order_lost_reason", description="Reason if order lost"), + ], + }, +] + +# --------------------------------------------------------------------------- +# ERPNext Inventory Tools +# --------------------------------------------------------------------------- + +ERPNEXT_INVENTORY_TOOLS = [ + { + "tool_name": "erpnext_get_items", + "description": "List ERPNext items with optional search across item_code, item_name, and item_group. Filter by item_group, is_stock_item, or disabled status.", + "function_path": "huf.ai.tools.erpnext_inventory.handle_get_items", + "category": "ERPNext Inventory", + "parameters": [ + _p("search", description="Search across item_code, item_name, or item_group"), + _p("item_group", description="Filter by item group"), + _p("is_stock_item", type="Check", description="Filter by stock item flag"), + _p("disabled", type="Check", description="Include disabled items (default 0)"), + _p("limit", type="integer", description="Max items to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_get_item", + "description": "Get a single ERPNext item by name with full details including item_defaults child table.", + "function_path": "huf.ai.tools.erpnext_inventory.handle_get_item", + "category": "ERPNext Inventory", + "parameters": [ + _p("name", required=True, description="Item ID/name (item_code)"), + ], + }, + { + "tool_name": "erpnext_get_item_prices", + "description": "List ERPNext item prices for an item with optional filters (price_list, buying, selling).", + "function_path": "huf.ai.tools.erpnext_inventory.handle_get_item_prices", + "category": "ERPNext Inventory", + "parameters": [ + _p("item_code", description="Filter by item code"), + _p("price_list", description="Filter by price list name"), + _p("buying", type="Check", description="Filter buying prices"), + _p("selling", type="Check", description="Filter selling prices"), + _p("limit", type="integer", description="Max prices to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_get_boms", + "description": "List ERPNext BOMs (Bill of Materials) with optional filters (item, is_active, is_default, company).", + "function_path": "huf.ai.tools.erpnext_inventory.handle_get_boms", + "category": "ERPNext Inventory", + "parameters": [ + _p("item", description="Filter by finished item code"), + _p("is_active", type="Check", description="Filter by active status"), + _p("is_default", type="Check", description="Filter by default status"), + _p("company", description="Filter by company"), + _p("limit", type="integer", description="Max BOMs to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_get_bom", + "description": "Get a single ERPNext BOM by name with items and operations child tables.", + "function_path": "huf.ai.tools.erpnext_inventory.handle_get_bom", + "category": "ERPNext Inventory", + "parameters": [ + _p("name", required=True, description="BOM ID/name"), + ], + }, + { + "tool_name": "erpnext_create_bom", + "description": "Create a draft ERPNext BOM. Provide finished item, quantity, and raw material line items.", + "function_path": "huf.ai.tools.erpnext_inventory.handle_create_bom", + "category": "ERPNext Inventory", + "parameters": [ + _p("item", required=True, description="Finished item code"), + _p("quantity", type="number", description="Quantity to manufacture (default 1)"), + _p("uom", description="Unit of measure"), + _p("company", description="Company name"), + _p("is_default", type="Check", description="Set as default BOM (default 1)"), + _p("items", type="json", description="Raw material lines: [{item_code, qty, uom, rate}]"), + ], + }, + { + "tool_name": "erpnext_get_stock_balance", + "description": "Get current stock balance per item and warehouse from Stock Ledger Entry. Optionally filter by item_code, warehouse, or as_of_date.", + "function_path": "huf.ai.tools.erpnext_inventory.handle_get_stock_balance", + "category": "ERPNext Inventory", + "parameters": [ + _p("item_code", description="Filter by item code"), + _p("warehouse", description="Filter by warehouse"), + _p("as_of_date", description="Balance as of date (YYYY-MM-DD)"), + ], + }, + { + "tool_name": "erpnext_get_stock_movements", + "description": "List ERPNext stock ledger entries with optional filters (item_code, warehouse, date range, voucher_type).", + "function_path": "huf.ai.tools.erpnext_inventory.handle_get_stock_movements", + "category": "ERPNext Inventory", + "parameters": [ + _p("item_code", description="Filter by item code"), + _p("warehouse", description="Filter by warehouse"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("voucher_type", description="Filter by voucher type"), + _p("limit", type="integer", description="Max entries to fetch (default 50)"), + ], + }, + { + "tool_name": "erpnext_get_stock_entries", + "description": "List ERPNext stock entry documents (Material Issue, Receipt, Transfer, Manufacture) with optional filters.", + "function_path": "huf.ai.tools.erpnext_inventory.handle_get_stock_entries", + "category": "ERPNext Inventory", + "parameters": [ + _p("stock_entry_type", description="Filter by type: Material Issue, Material Receipt, Material Transfer, Manufacture"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("docstatus", type="integer", description="Filter by docstatus: 0=Draft, 1=Submitted, 2=Cancelled"), + _p("limit", type="integer", description="Max entries to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_get_warehouses", + "description": "List ERPNext warehouses with optional filters (company, warehouse_type, disabled).", + "function_path": "huf.ai.tools.erpnext_inventory.handle_get_warehouses", + "category": "ERPNext Inventory", + "parameters": [ + _p("company", description="Filter by company"), + _p("warehouse_type", description="Filter by warehouse type"), + _p("disabled", type="Check", description="Include disabled warehouses (default 0)"), + _p("limit", type="integer", description="Max warehouses to fetch (default 50)"), + ], + }, + { + "tool_name": "erpnext_get_delivery_notes", + "description": "List ERPNext delivery notes with optional filters (customer, date range, docstatus).", + "function_path": "huf.ai.tools.erpnext_inventory.handle_get_delivery_notes", + "category": "ERPNext Inventory", + "parameters": [ + _p("customer", description="Filter by customer ID"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("docstatus", type="integer", description="Filter by docstatus"), + _p("limit", type="integer", description="Max notes to fetch (default 20)"), + ], + }, + { + "tool_name": "erpnext_get_purchase_receipts", + "description": "List ERPNext purchase receipts with optional filters (supplier, date range, docstatus).", + "function_path": "huf.ai.tools.erpnext_inventory.handle_get_purchase_receipts", + "category": "ERPNext Inventory", + "parameters": [ + _p("supplier", description="Filter by supplier ID"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("docstatus", type="integer", description="Filter by docstatus"), + _p("limit", type="integer", description="Max receipts to fetch (default 20)"), + ], + }, +] + +# --------------------------------------------------------------------------- +# ERPNext Report Tools +# --------------------------------------------------------------------------- + +ERPNEXT_REPORT_TOOLS = [ + { + "tool_name": "erpnext_balance_sheet", + "description": "Run ERPNext Balance Sheet report. Key filters: company (required), fiscal_year or from_fiscal_year/to_fiscal_year, periodicity (Monthly/Quarterly/Half-Yearly/Yearly), accumulated_values.", + "function_path": "huf.ai.tools.erpnext_reports.handle_balance_sheet", + "category": "ERPNext Reports", + "parameters": [ + _p("company", required=True, description="Company name"), + _p("fiscal_year", description="Fiscal year"), + _p("from_fiscal_year", description="From fiscal year"), + _p("to_fiscal_year", description="To fiscal year"), + _p("periodicity", description="Monthly, Quarterly, Half-Yearly, or Yearly"), + _p("accumulated_values", type="Check", description="Show accumulated values (default 1)"), + ], + }, + { + "tool_name": "erpnext_profit_and_loss", + "description": "Run ERPNext Profit and Loss Statement report. Key filters: company (required), fiscal_year, periodicity, from_date, to_date.", + "function_path": "huf.ai.tools.erpnext_reports.handle_profit_and_loss", + "category": "ERPNext Reports", + "parameters": [ + _p("company", required=True, description="Company name"), + _p("fiscal_year", description="Fiscal year"), + _p("periodicity", description="Monthly, Quarterly, Half-Yearly, or Yearly"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + ], + }, + { + "tool_name": "erpnext_trial_balance", + "description": "Run ERPNext Trial Balance report. Key filters: company (required), from_date, to_date, show_zero_values.", + "function_path": "huf.ai.tools.erpnext_reports.handle_trial_balance", + "category": "ERPNext Reports", + "parameters": [ + _p("company", required=True, description="Company name"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("show_zero_values", type="Check", description="Show accounts with zero balance (default 0)"), + ], + }, + { + "tool_name": "erpnext_general_ledger", + "description": "Run ERPNext General Ledger report. Key filters: company (required), from_date, to_date, account, party_type, party, voucher_no, group_by, limit.", + "function_path": "huf.ai.tools.erpnext_reports.handle_general_ledger", + "category": "ERPNext Reports", + "parameters": [ + _p("company", required=True, description="Company name"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("account", description="Account name/ID"), + _p("party_type", description="Party type"), + _p("party", description="Party name/ID"), + _p("voucher_no", description="Voucher number"), + _p("group_by", description="Group by Voucher or Group by Account"), + _p("limit", type="integer", description="Max rows to return (default 500)"), + ], + }, + { + "tool_name": "erpnext_accounts_receivable", + "description": "Run ERPNext Accounts Receivable report. Key filters: company (required), report_date, ageing_based_on (Due Date/Posting Date), range1/range2/range3, customer.", + "function_path": "huf.ai.tools.erpnext_reports.handle_accounts_receivable", + "category": "ERPNext Reports", + "parameters": [ + _p("company", required=True, description="Company name"), + _p("report_date", description="Report as-of date (YYYY-MM-DD)"), + _p("ageing_based_on", description="Due Date or Posting Date"), + _p("range1", type="integer", description="Ageing range 1 in days (default 30)"), + _p("range2", type="integer", description="Ageing range 2 in days (default 60)"), + _p("range3", type="integer", description="Ageing range 3 in days (default 90)"), + _p("customer", description="Filter by customer ID"), + _p("payment_terms_template", description="Filter by payment terms template"), + ], + }, + { + "tool_name": "erpnext_accounts_payable", + "description": "Run ERPNext Accounts Payable report. Key filters: company (required), report_date, ageing_based_on, range1/range2/range3, supplier.", + "function_path": "huf.ai.tools.erpnext_reports.handle_accounts_payable", + "category": "ERPNext Reports", + "parameters": [ + _p("company", required=True, description="Company name"), + _p("report_date", description="Report as-of date (YYYY-MM-DD)"), + _p("ageing_based_on", description="Due Date or Posting Date"), + _p("range1", type="integer", description="Ageing range 1 in days (default 30)"), + _p("range2", type="integer", description="Ageing range 2 in days (default 60)"), + _p("range3", type="integer", description="Ageing range 3 in days (default 90)"), + _p("supplier", description="Filter by supplier ID"), + ], + }, + { + "tool_name": "erpnext_bank_reconciliation", + "description": "Run ERPNext Bank Reconciliation Statement report. Key filters: company (required), account (bank account, required), from_date, to_date, include_pos_transactions.", + "function_path": "huf.ai.tools.erpnext_reports.handle_bank_reconciliation", + "category": "ERPNext Reports", + "parameters": [ + _p("company", required=True, description="Company name"), + _p("account", required=True, description="Bank account name/ID"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("include_pos_transactions", type="Check", description="Include POS transactions (default 0)"), + ], + }, + { + "tool_name": "erpnext_sales_register", + "description": "Run ERPNext Sales Register report. Key filters: company, from_date (required), to_date (required), customer, item_code.", + "function_path": "huf.ai.tools.erpnext_reports.handle_sales_register", + "category": "ERPNext Reports", + "parameters": [ + _p("company", description="Company name"), + _p("from_date", required=True, description="Start date (YYYY-MM-DD)"), + _p("to_date", required=True, description="End date (YYYY-MM-DD)"), + _p("customer", description="Filter by customer ID"), + _p("item_code", description="Filter by item code"), + ], + }, + { + "tool_name": "erpnext_sales_order_analysis", + "description": "Run ERPNext Sales Order Analysis report. Key filters: company, from_date, to_date, customer, item_code, status.", + "function_path": "huf.ai.tools.erpnext_reports.handle_sales_order_analysis", + "category": "ERPNext Reports", + "parameters": [ + _p("company", description="Company name"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("customer", description="Filter by customer ID"), + _p("item_code", description="Filter by item code"), + _p("status", description="Filter by status: Draft, To Deliver and Bill, Completed, etc."), + ], + }, + { + "tool_name": "erpnext_customer_acquisition", + "description": "Run ERPNext Customer Acquisition and Loyalty report. Key filters: company, from_date, to_date, customer_group, territory.", + "function_path": "huf.ai.tools.erpnext_reports.handle_customer_acquisition", + "category": "ERPNext Reports", + "parameters": [ + _p("company", description="Company name"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("customer_group", description="Filter by customer group"), + _p("territory", description="Filter by territory"), + ], + }, + { + "tool_name": "erpnext_stock_balance_report", + "description": "Run ERPNext Stock Balance report (native ERPNext report). Key filters: company, from_date, to_date, item_code, warehouse, item_group.", + "function_path": "huf.ai.tools.erpnext_reports.handle_stock_balance_report", + "category": "ERPNext Reports", + "parameters": [ + _p("company", description="Company name"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("item_code", description="Filter by item code"), + _p("warehouse", description="Filter by warehouse"), + _p("item_group", description="Filter by item group"), + ], + }, + { + "tool_name": "erpnext_stock_ledger_report", + "description": "Run ERPNext Stock Ledger report (native ERPNext report). Key filters: company, from_date (required), to_date (required), item_code, warehouse, voucher_no.", + "function_path": "huf.ai.tools.erpnext_reports.handle_stock_ledger_report", + "category": "ERPNext Reports", + "parameters": [ + _p("company", description="Company name"), + _p("from_date", required=True, description="Start date (YYYY-MM-DD)"), + _p("to_date", required=True, description="End date (YYYY-MM-DD)"), + _p("item_code", description="Filter by item code"), + _p("warehouse", description="Filter by warehouse"), + _p("voucher_no", description="Filter by voucher number"), + ], + }, + { + "tool_name": "erpnext_item_wise_sales", + "description": "Run ERPNext Item-wise Sales Register report. Key filters: company, from_date, to_date, item_code, customer.", + "function_path": "huf.ai.tools.erpnext_reports.handle_item_wise_sales", + "category": "ERPNext Reports", + "parameters": [ + _p("company", description="Company name"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("item_code", description="Filter by item code"), + _p("customer", description="Filter by customer ID"), + ], + }, + { + "tool_name": "erpnext_gross_profit", + "description": "Run ERPNext Gross Profit report. Key filters: company, from_date, to_date, group_by (Invoice/Item Code/Item Group/Customer/Customer Group).", + "function_path": "huf.ai.tools.erpnext_reports.handle_gross_profit", + "category": "ERPNext Reports", + "parameters": [ + _p("company", description="Company name"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("group_by", description="Group by: Invoice, Item Code, Item Group, Customer, Customer Group"), + ], + }, +] + # --------------------------------------------------------------------------- # Master list: every tool grouped for easy iteration # --------------------------------------------------------------------------- @@ -587,4 +1230,8 @@ def _p(name, type="string", required=False, description=""): + CRM_TOOLS + HELPDESK_TOOLS + RAVEN_TOOLS + + ERPNEXT_TOOLS + + ERPNEXT_CRM_TOOLS + + ERPNEXT_INVENTORY_TOOLS + + ERPNEXT_REPORT_TOOLS ) diff --git a/huf/ai/tools/erpnext.py b/huf/ai/tools/erpnext.py new file mode 100644 index 00000000..b9888fce --- /dev/null +++ b/huf/ai/tools/erpnext.py @@ -0,0 +1,683 @@ +""" +ERPNext integration tools for financial and business documents. +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _erpnext_installed(): + try: + return "erpnext" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}) + + +def _docstatus_label(ds): + return {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(ds, str(ds)) + + +# --------------------------------------------------------------------------- +# Sales Invoice +# --------------------------------------------------------------------------- + +def handle_get_sales_invoices(**kwargs) -> str: + """List Sales Invoices with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + customer = kwargs.get("customer") + status = kwargs.get("status") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + limit = int(kwargs.get("limit", 20)) + + if customer: + filters.append(["Sales Invoice", "customer", "=", customer]) + + if status: + if status == "Draft": + filters.append(["Sales Invoice", "docstatus", "=", 0]) + elif status == "Submitted": + filters.append(["Sales Invoice", "docstatus", "=", 1]) + elif status == "Cancelled": + filters.append(["Sales Invoice", "docstatus", "=", 2]) + else: + filters.append(["Sales Invoice", "status", "=", status]) + + if from_date: + filters.append(["Sales Invoice", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Sales Invoice", "posting_date", "<=", to_date]) + + fields = [ + "name", + "customer", + "customer_name", + "posting_date", + "due_date", + "grand_total", + "outstanding_amount", + "status", + "docstatus", + ] + + invoices = frappe.get_all( + "Sales Invoice", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + for inv in invoices: + inv["docstatus_label"] = _docstatus_label(inv.get("docstatus")) + + return json.dumps({"success": True, "count": len(invoices), "results": invoices}) + except Exception as e: + frappe.log_error(f"ERPNext Get Sales Invoices Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_get_sales_invoice(**kwargs) -> str: + """Get a single Sales Invoice by name/ID with all fields including items.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Sales Invoice", name): + return _error(f"Sales Invoice {name} not found") + + doc = frappe.get_doc("Sales Invoice", name) + result = doc.as_dict() + result["docstatus_label"] = _docstatus_label(result.get("docstatus")) + return json.dumps({"success": True, "results": result}) + except Exception as e: + frappe.log_error(f"ERPNext Get Sales Invoice Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_create_sales_invoice(**kwargs) -> str: + """Create a draft Sales Invoice.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + customer = kwargs.get("customer") + if not customer: + return _error("customer is required") + + doc = frappe.new_doc("Sales Invoice") + doc.customer = customer + doc.company = kwargs.get("company", "") + doc.posting_date = kwargs.get("posting_date", "") + + items = kwargs.get("items", []) + if isinstance(items, str): + items = json.loads(items) + for item in items: + doc.append( + "items", + { + "item_code": item.get("item_code"), + "qty": item.get("qty", 1), + "rate": item.get("rate", 0), + }, + ) + + doc.insert(ignore_permissions=True) + return json.dumps( + {"success": True, "results": {"name": doc.name, "customer": doc.customer}} + ) + except Exception as e: + frappe.log_error(f"ERPNext Create Sales Invoice Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Purchase Invoice +# --------------------------------------------------------------------------- + +def handle_get_purchase_invoices(**kwargs) -> str: + """List Purchase Invoices with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + supplier = kwargs.get("supplier") + status = kwargs.get("status") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + limit = int(kwargs.get("limit", 20)) + + if supplier: + filters.append(["Purchase Invoice", "supplier", "=", supplier]) + + if status: + if status == "Draft": + filters.append(["Purchase Invoice", "docstatus", "=", 0]) + elif status == "Submitted": + filters.append(["Purchase Invoice", "docstatus", "=", 1]) + elif status == "Cancelled": + filters.append(["Purchase Invoice", "docstatus", "=", 2]) + else: + filters.append(["Purchase Invoice", "status", "=", status]) + + if from_date: + filters.append(["Purchase Invoice", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Purchase Invoice", "posting_date", "<=", to_date]) + + fields = [ + "name", + "supplier", + "supplier_name", + "posting_date", + "due_date", + "bill_no", + "total", + "grand_total", + "outstanding_amount", + "status", + "docstatus", + ] + + invoices = frappe.get_all( + "Purchase Invoice", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + for inv in invoices: + inv["docstatus_label"] = _docstatus_label(inv.get("docstatus")) + + return json.dumps({"success": True, "count": len(invoices), "results": invoices}) + except Exception as e: + frappe.log_error(f"ERPNext Get Purchase Invoices Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_get_purchase_invoice(**kwargs) -> str: + """Get a single Purchase Invoice by name/ID with all fields including items.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Purchase Invoice", name): + return _error(f"Purchase Invoice {name} not found") + + doc = frappe.get_doc("Purchase Invoice", name) + result = doc.as_dict() + result["docstatus_label"] = _docstatus_label(result.get("docstatus")) + return json.dumps({"success": True, "results": result}) + except Exception as e: + frappe.log_error(f"ERPNext Get Purchase Invoice Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Payment Entry +# --------------------------------------------------------------------------- + +def handle_get_payments(**kwargs) -> str: + """List Payment Entries with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + party_type = kwargs.get("party_type") + party = kwargs.get("party") + payment_type = kwargs.get("payment_type") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + limit = int(kwargs.get("limit", 20)) + + if party_type: + filters.append(["Payment Entry", "party_type", "=", party_type]) + if party: + filters.append(["Payment Entry", "party", "=", party]) + if payment_type: + filters.append(["Payment Entry", "payment_type", "=", payment_type]) + if from_date: + filters.append(["Payment Entry", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Payment Entry", "posting_date", "<=", to_date]) + + fields = [ + "name", + "payment_type", + "party_type", + "party", + "party_name", + "paid_amount", + "posting_date", + "mode_of_payment", + "status", + ] + + payments = frappe.get_all( + "Payment Entry", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + return json.dumps({"success": True, "count": len(payments), "results": payments}) + except Exception as e: + frappe.log_error(f"ERPNext Get Payments Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_create_payment(**kwargs) -> str: + """Create a draft Payment Entry. Optionally link to an invoice.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + payment_type = kwargs.get("payment_type") + party_type = kwargs.get("party_type") + party = kwargs.get("party") + if not payment_type: + return _error("payment_type is required") + if not party_type: + return _error("party_type is required") + if not party: + return _error("party is required") + + doc = frappe.new_doc("Payment Entry") + doc.payment_type = payment_type + doc.party_type = party_type + doc.party = party + doc.company = kwargs.get("company", "") + doc.posting_date = kwargs.get("posting_date", "") + doc.mode_of_payment = kwargs.get("mode_of_payment", "") + doc.paid_from = kwargs.get("paid_from", "") + doc.paid_to = kwargs.get("paid_to", "") + doc.paid_amount = float(kwargs.get("paid_amount", 0)) + doc.received_amount = float(kwargs.get("received_amount", doc.paid_amount)) + + invoice_name = kwargs.get("invoice_name") + if invoice_name: + ref_doctype = None + if frappe.db.exists("Sales Invoice", invoice_name): + ref_doctype = "Sales Invoice" + elif frappe.db.exists("Purchase Invoice", invoice_name): + ref_doctype = "Purchase Invoice" + + if ref_doctype: + doc.append( + "references", + { + "reference_doctype": ref_doctype, + "reference_name": invoice_name, + "allocated_amount": doc.paid_amount, + }, + ) + + doc.insert(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name}}) + except Exception as e: + frappe.log_error(f"ERPNext Create Payment Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Quotation +# --------------------------------------------------------------------------- + +def handle_get_quotations(**kwargs) -> str: + """List Quotations with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + party_name = kwargs.get("party_name") + status = kwargs.get("status") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + limit = int(kwargs.get("limit", 20)) + + if party_name: + filters.append(["Quotation", "party_name", "=", party_name]) + if status: + filters.append(["Quotation", "status", "=", status]) + if from_date: + filters.append(["Quotation", "transaction_date", ">=", from_date]) + if to_date: + filters.append(["Quotation", "transaction_date", "<=", to_date]) + + fields = [ + "name", + "quotation_to", + "party_name", + "customer_name", + "company", + "transaction_date", + "valid_till", + "currency", + "total", + "grand_total", + "status", + "docstatus", + ] + + quotes = frappe.get_all( + "Quotation", + fields=fields, + filters=filters, + limit=limit, + order_by="transaction_date desc", + ) + + for q in quotes: + q["docstatus_label"] = _docstatus_label(q.get("docstatus")) + + return json.dumps({"success": True, "count": len(quotes), "results": quotes}) + except Exception as e: + frappe.log_error(f"ERPNext Get Quotations Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_create_quotation(**kwargs) -> str: + """Create a draft Quotation.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + quotation_to = kwargs.get("quotation_to") + party_name = kwargs.get("party_name") + if not quotation_to: + return _error("quotation_to is required") + if not party_name: + return _error("party_name is required") + + doc = frappe.new_doc("Quotation") + doc.quotation_to = quotation_to + doc.party_name = party_name + doc.company = kwargs.get("company", "") + doc.transaction_date = kwargs.get("transaction_date", "") + doc.valid_till = kwargs.get("valid_till", "") + + items = kwargs.get("items", []) + if isinstance(items, str): + items = json.loads(items) + for item in items: + doc.append( + "items", + { + "item_code": item.get("item_code"), + "qty": item.get("qty", 1), + "rate": item.get("rate", 0), + }, + ) + + doc.insert(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name}}) + except Exception as e: + frappe.log_error(f"ERPNext Create Quotation Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Customer +# --------------------------------------------------------------------------- + +def handle_get_customers(**kwargs) -> str: + """List/search Customers with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + customer_group = kwargs.get("customer_group") + territory = kwargs.get("territory") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + + if customer_group: + filters.append(["Customer", "customer_group", "=", customer_group]) + if territory: + filters.append(["Customer", "territory", "=", territory]) + + or_filters = [] + if search: + or_filters = [ + ["Customer", "name", "like", f"%{search}%"], + ["Customer", "customer_name", "like", f"%{search}%"], + ["Customer", "customer_group", "like", f"%{search}%"], + ] + + fields = [ + "name", + "customer_name", + "customer_type", + "customer_group", + "territory", + "default_currency", + "disabled", + ] + + customers = frappe.get_all( + "Customer", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(customers), "results": customers}) + except Exception as e: + frappe.log_error(f"ERPNext Get Customers Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_get_customer(**kwargs) -> str: + """Get a single Customer by name/ID with linked addresses and contacts.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Customer", name): + return _error(f"Customer {name} not found") + + doc = frappe.get_doc("Customer", name) + result = doc.as_dict() + + # Linked addresses via Dynamic Link + address_links = frappe.get_all( + "Dynamic Link", + filters={ + "link_doctype": "Customer", + "link_name": name, + "parenttype": "Address", + }, + fields=["parent"], + ) + result["addresses"] = [] + for link in address_links: + addr = frappe.get_doc("Address", link.parent) + result["addresses"].append(addr.as_dict()) + + # Linked contacts via Dynamic Link + contact_links = frappe.get_all( + "Dynamic Link", + filters={ + "link_doctype": "Customer", + "link_name": name, + "parenttype": "Contact", + }, + fields=["parent"], + ) + result["contacts"] = [] + for link in contact_links: + contact = frappe.get_doc("Contact", link.parent) + result["contacts"].append(contact.as_dict()) + + return json.dumps({"success": True, "results": result}) + except Exception as e: + frappe.log_error(f"ERPNext Get Customer Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# GL Entry (read-only) +# --------------------------------------------------------------------------- + +def handle_get_account_ledger(**kwargs) -> str: + """Query GL Entries for an account with running balance. GL Entry is read-only.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + account = kwargs.get("account") + if not account: + return _error("account is required") + + filters = [["GL Entry", "account", "=", account]] + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + party_type = kwargs.get("party_type") + party = kwargs.get("party") + limit = int(kwargs.get("limit", 50)) + + if from_date: + filters.append(["GL Entry", "posting_date", ">=", from_date]) + if to_date: + filters.append(["GL Entry", "posting_date", "<=", to_date]) + if party_type: + filters.append(["GL Entry", "party_type", "=", party_type]) + if party: + filters.append(["GL Entry", "party", "=", party]) + + fields = [ + "name", + "account", + "party_type", + "party", + "posting_date", + "debit", + "credit", + "voucher_type", + "voucher_no", + "cost_center", + "remarks", + ] + + entries = frappe.get_all( + "GL Entry", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date asc, creation asc", + ) + + running_balance = 0.0 + for entry in entries: + running_balance += float(entry.get("debit", 0) or 0) - float(entry.get("credit", 0) or 0) + entry["running_balance"] = running_balance + + return json.dumps({"success": True, "count": len(entries), "results": entries}) + except Exception as e: + frappe.log_error(f"ERPNext Get Account Ledger Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Journal Entry +# --------------------------------------------------------------------------- + +def handle_create_journal_entry(**kwargs) -> str: + """Create a draft Journal Entry with account lines.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + doc = frappe.new_doc("Journal Entry") + doc.voucher_type = kwargs.get("voucher_type", "Journal Entry") + doc.posting_date = kwargs.get("posting_date", "") + doc.company = kwargs.get("company", "") + doc.user_remark = kwargs.get("user_remark", "") + + accounts = kwargs.get("accounts", []) + if isinstance(accounts, str): + accounts = json.loads(accounts) + for acc in accounts: + doc.append( + "accounts", + { + "account": acc.get("account"), + "debit_in_account_currency": acc.get("debit_in_account_currency", 0), + "credit_in_account_currency": acc.get("credit_in_account_currency", 0), + "party_type": acc.get("party_type", ""), + "party": acc.get("party", ""), + "cost_center": acc.get("cost_center", ""), + }, + ) + + doc.insert(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name}}) + except Exception as e: + frappe.log_error(f"ERPNext Create Journal Entry Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Request for Quotation +# --------------------------------------------------------------------------- + +def handle_get_rfqs(**kwargs) -> str: + """List Request for Quotation documents with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + status = kwargs.get("status") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + limit = int(kwargs.get("limit", 20)) + + if status: + filters.append(["Request for Quotation", "status", "=", status]) + if from_date: + filters.append(["Request for Quotation", "transaction_date", ">=", from_date]) + if to_date: + filters.append(["Request for Quotation", "transaction_date", "<=", to_date]) + + fields = ["name", "company", "transaction_date", "status"] + + rfqs = frappe.get_all( + "Request for Quotation", + fields=fields, + filters=filters, + limit=limit, + order_by="transaction_date desc", + ) + + return json.dumps({"success": True, "count": len(rfqs), "results": rfqs}) + except Exception as e: + frappe.log_error(f"ERPNext Get RFQs Error: {e}", "ERPNext Tool") + return _error(str(e)) diff --git a/huf/ai/tools/erpnext_crm.py b/huf/ai/tools/erpnext_crm.py new file mode 100644 index 00000000..dd455da3 --- /dev/null +++ b/huf/ai/tools/erpnext_crm.py @@ -0,0 +1,284 @@ +""" +ERPNext CRM integration tools (Lead, Opportunity). +Different from standalone Frappe CRM (CRM Lead / CRM Deal). +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _erpnext_installed(): + try: + return "erpnext" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}) + + +# --------------------------------------------------------------------------- +# Lead +# --------------------------------------------------------------------------- + +def handle_get_leads(**kwargs) -> str: + """List ERPNext leads with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + status = kwargs.get("status") + lead_owner = kwargs.get("lead_owner") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + + if status: + filters.append(["Lead", "status", "=", status]) + if lead_owner: + filters.append(["Lead", "lead_owner", "=", lead_owner]) + + or_filters = [] + if search: + or_filters = [ + ["Lead", "lead_name", "like", f"%{search}%"], + ["Lead", "company_name", "like", f"%{search}%"], + ["Lead", "email_id", "like", f"%{search}%"], + ] + + fields = [ + "name", + "lead_name", + "company_name", + "email_id", + "status", + "lead_owner", + "mobile_no", + "territory", + "modified", + ] + + leads = frappe.get_all( + "Lead", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(leads), "results": leads}) + except Exception as e: + frappe.log_error(f"ERPNext CRM Get Leads Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +def handle_get_lead(**kwargs) -> str: + """Get a single ERPNext Lead by name/ID with all fields.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Lead", name): + return _error(f"Lead {name} not found") + + doc = frappe.get_doc("Lead", name) + return json.dumps({"success": True, "results": doc.as_dict()}) + except Exception as e: + frappe.log_error(f"ERPNext CRM Get Lead Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +def handle_create_lead(**kwargs) -> str: + """Create a new ERPNext Lead.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + lead_name = kwargs.get("lead_name") + if not lead_name: + return _error("lead_name is required") + + doc = frappe.new_doc("Lead") + doc.lead_name = lead_name + doc.company_name = kwargs.get("company_name", "") + doc.email_id = kwargs.get("email_id", "") + doc.mobile_no = kwargs.get("mobile_no", "") + doc.phone = kwargs.get("phone", "") + doc.lead_owner = kwargs.get("lead_owner", "") + doc.status = kwargs.get("status", "Lead") + doc.type = kwargs.get("type", "") + doc.market_segment = kwargs.get("market_segment", "") + doc.industry = kwargs.get("industry", "") + doc.territory = kwargs.get("territory", "") + doc.website = kwargs.get("website", "") + doc.insert(ignore_permissions=True) + + return json.dumps( + {"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}} + ) + except Exception as e: + frappe.log_error(f"ERPNext CRM Create Lead Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +def handle_update_lead(**kwargs) -> str: + """Update fields on an existing ERPNext Lead.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Lead", name): + return _error(f"Lead {name} not found") + + doc = frappe.get_doc("Lead", name) + updatable = [ + "status", + "lead_owner", + "email_id", + "mobile_no", + "territory", + "qualification_status", + "company_name", + "phone", + "website", + "industry", + "market_segment", + "type", + ] + for field in updatable: + if field in kwargs: + setattr(doc, field, kwargs[field]) + + doc.save(ignore_permissions=True) + return json.dumps( + {"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}} + ) + except Exception as e: + frappe.log_error(f"ERPNext CRM Update Lead Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Opportunity +# --------------------------------------------------------------------------- + +def handle_get_opportunities(**kwargs) -> str: + """List ERPNext Opportunities with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + status = kwargs.get("status") + party_name = kwargs.get("party_name") + from_date = kwargs.get("from_date") + limit = int(kwargs.get("limit", 20)) + + if status: + filters.append(["Opportunity", "status", "=", status]) + if party_name: + filters.append(["Opportunity", "party_name", "=", party_name]) + if from_date: + filters.append(["Opportunity", "expected_closing", ">=", from_date]) + + fields = [ + "name", + "title", + "party_name", + "customer_name", + "status", + "opportunity_amount", + "sales_stage", + "probability", + "expected_closing", + ] + + opportunities = frappe.get_all( + "Opportunity", + fields=fields, + filters=filters, + limit=limit, + order_by="modified desc", + ) + + return json.dumps( + {"success": True, "count": len(opportunities), "results": opportunities} + ) + except Exception as e: + frappe.log_error(f"ERPNext CRM Get Opportunities Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +def handle_create_opportunity(**kwargs) -> str: + """Create a new ERPNext Opportunity.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + opportunity_from = kwargs.get("opportunity_from") + party_name = kwargs.get("party_name") + if not opportunity_from: + return _error("opportunity_from is required") + if not party_name: + return _error("party_name is required") + + doc = frappe.new_doc("Opportunity") + doc.opportunity_from = opportunity_from + doc.party_name = party_name + doc.title = kwargs.get("title", "") + doc.opportunity_type = kwargs.get("opportunity_type", "") + doc.expected_closing = kwargs.get("expected_closing", "") + doc.opportunity_amount = float(kwargs.get("opportunity_amount", 0)) + doc.sales_stage = kwargs.get("sales_stage", "") + doc.probability = float(kwargs.get("probability", 0)) + doc.currency = kwargs.get("currency", "") + doc.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": doc.name}}) + except Exception as e: + frappe.log_error(f"ERPNext CRM Create Opportunity Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +def handle_update_opportunity(**kwargs) -> str: + """Update fields on an existing ERPNext Opportunity.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Opportunity", name): + return _error(f"Opportunity {name} not found") + + doc = frappe.get_doc("Opportunity", name) + updatable = [ + "status", + "opportunity_amount", + "sales_stage", + "probability", + "expected_closing", + "order_lost_reason", + ] + for field in updatable: + if field in kwargs: + if field in ("opportunity_amount", "probability"): + setattr(doc, field, float(kwargs[field])) + else: + setattr(doc, field, kwargs[field]) + + doc.save(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name}}) + except Exception as e: + frappe.log_error(f"ERPNext CRM Update Opportunity Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) diff --git a/huf/ai/tools/erpnext_inventory.py b/huf/ai/tools/erpnext_inventory.py new file mode 100644 index 00000000..0610745a --- /dev/null +++ b/huf/ai/tools/erpnext_inventory.py @@ -0,0 +1,554 @@ +""" +ERPNext integration tools for Items, BOM, Inventory and Stock. +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _erpnext_installed(): + try: + return "erpnext" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}) + + +def _docstatus_label(ds): + return {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(ds, str(ds)) + + +# --------------------------------------------------------------------------- +# Items +# --------------------------------------------------------------------------- + +def handle_get_items(**kwargs) -> str: + """List ERPNext items with optional search and filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [["Item", "disabled", "=", int(kwargs.get("disabled", 0))]] + item_group = kwargs.get("item_group") + is_stock_item = kwargs.get("is_stock_item") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + + if item_group: + filters.append(["Item", "item_group", "=", item_group]) + if is_stock_item is not None: + filters.append(["Item", "is_stock_item", "=", int(is_stock_item)]) + + or_filters = [] + if search: + or_filters = [ + ["Item", "item_code", "like", f"%{search}%"], + ["Item", "item_name", "like", f"%{search}%"], + ["Item", "item_group", "like", f"%{search}%"], + ] + + fields = [ + "name", + "item_code", + "item_name", + "item_group", + "stock_uom", + "is_stock_item", + "disabled", + "standard_rate", + "valuation_rate", + ] + + items = frappe.get_all( + "Item", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(items), "results": items}) + except Exception as e: + frappe.log_error(f"ERPNext Get Items Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_get_item(**kwargs) -> str: + """Get a single ERPNext item by item_code with item_defaults child table.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Item", name): + return _error(f"Item {name} not found") + + doc = frappe.get_doc("Item", name) + result = doc.as_dict() + return json.dumps({"success": True, "results": result}) + except Exception as e: + frappe.log_error(f"ERPNext Get Item Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_get_item_prices(**kwargs) -> str: + """List ERPNext item prices for an item with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + item_code = kwargs.get("item_code") + price_list = kwargs.get("price_list") + buying = kwargs.get("buying") + selling = kwargs.get("selling") + limit = int(kwargs.get("limit", 20)) + + if item_code: + filters.append(["Item Price", "item_code", "=", item_code]) + if price_list: + filters.append(["Item Price", "price_list", "=", price_list]) + if buying is not None: + filters.append(["Item Price", "buying", "=", int(buying)]) + if selling is not None: + filters.append(["Item Price", "selling", "=", int(selling)]) + + fields = [ + "name", + "item_code", + "price_list", + "buying", + "selling", + "currency", + "price_list_rate", + "uom", + "valid_from", + ] + + prices = frappe.get_all( + "Item Price", + fields=fields, + filters=filters, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(prices), "results": prices}) + except Exception as e: + frappe.log_error(f"ERPNext Get Item Prices Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# BOM +# --------------------------------------------------------------------------- + +def handle_get_boms(**kwargs) -> str: + """List ERPNext BOMs with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + item = kwargs.get("item") + is_active = kwargs.get("is_active") + is_default = kwargs.get("is_default") + company = kwargs.get("company") + limit = int(kwargs.get("limit", 20)) + + if item: + filters.append(["BOM", "item", "=", item]) + if is_active is not None: + filters.append(["BOM", "is_active", "=", int(is_active)]) + if is_default is not None: + filters.append(["BOM", "is_default", "=", int(is_default)]) + if company: + filters.append(["BOM", "company", "=", company]) + + fields = [ + "name", + "item", + "item_name", + "quantity", + "uom", + "is_active", + "is_default", + "total_cost", + "company", + "modified", + ] + + boms = frappe.get_all( + "BOM", + fields=fields, + filters=filters, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(boms), "results": boms}) + except Exception as e: + frappe.log_error(f"ERPNext Get BOMs Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_get_bom(**kwargs) -> str: + """Get a single ERPNext BOM by name with items and operations child tables.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("BOM", name): + return _error(f"BOM {name} not found") + + doc = frappe.get_doc("BOM", name) + result = doc.as_dict() + return json.dumps({"success": True, "results": result}) + except Exception as e: + frappe.log_error(f"ERPNext Get BOM Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_create_bom(**kwargs) -> str: + """Create a draft ERPNext BOM. Provide item, quantity, and line items.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + item = kwargs.get("item") + if not item: + return _error("item is required") + + doc = frappe.new_doc("BOM") + doc.item = item + doc.quantity = float(kwargs.get("quantity", 1)) + doc.uom = kwargs.get("uom", "") + doc.company = kwargs.get("company", "") + doc.is_default = int(kwargs.get("is_default", 1)) + doc.is_active = 1 + + items = kwargs.get("items", []) + if isinstance(items, str): + items = json.loads(items) + for row in items: + doc.append( + "items", + { + "item_code": row.get("item_code"), + "qty": float(row.get("qty", 1)), + "uom": row.get("uom", ""), + "rate": float(row.get("rate", 0)), + }, + ) + + doc.insert(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name, "item": doc.item}}) + except Exception as e: + frappe.log_error(f"ERPNext Create BOM Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Stock & Inventory +# --------------------------------------------------------------------------- + +def handle_get_stock_balance(**kwargs) -> str: + """Get current stock balance per item and warehouse from Stock Ledger Entry.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = {"is_cancelled": 0} + item_code = kwargs.get("item_code") + warehouse = kwargs.get("warehouse") + as_of_date = kwargs.get("as_of_date") + + if item_code: + filters["item_code"] = item_code + if warehouse: + filters["warehouse"] = warehouse + if as_of_date: + filters["posting_date"] = ["<=", as_of_date] + + sle_list = frappe.get_all( + "Stock Ledger Entry", + fields=[ + "item_code", + "warehouse", + "qty_after_transaction", + "valuation_rate", + "posting_date", + "posting_time", + ], + filters=filters, + order_by="posting_date desc, posting_time desc, creation desc", + limit=500, + ) + + seen = {} + for sle in sle_list: + key = (sle["item_code"], sle["warehouse"]) + if key not in seen: + seen[key] = sle + + results = [] + for sle in seen.values(): + results.append({ + "item_code": sle["item_code"], + "warehouse": sle["warehouse"], + "qty_after_transaction": sle["qty_after_transaction"], + "valuation_rate": sle["valuation_rate"], + "stock_value": ( + (sle["qty_after_transaction"] or 0) * (sle["valuation_rate"] or 0) + ), + }) + + return json.dumps({"success": True, "count": len(results), "results": results}) + except Exception as e: + frappe.log_error(f"ERPNext Get Stock Balance Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_get_stock_movements(**kwargs) -> str: + """List ERPNext stock ledger entries with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [["Stock Ledger Entry", "is_cancelled", "=", 0]] + item_code = kwargs.get("item_code") + warehouse = kwargs.get("warehouse") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + voucher_type = kwargs.get("voucher_type") + limit = int(kwargs.get("limit", 50)) + + if item_code: + filters.append(["Stock Ledger Entry", "item_code", "=", item_code]) + if warehouse: + filters.append(["Stock Ledger Entry", "warehouse", "=", warehouse]) + if from_date: + filters.append(["Stock Ledger Entry", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Stock Ledger Entry", "posting_date", "<=", to_date]) + if voucher_type: + filters.append(["Stock Ledger Entry", "voucher_type", "=", voucher_type]) + + fields = [ + "name", + "item_code", + "warehouse", + "posting_date", + "voucher_type", + "voucher_no", + "actual_qty", + "qty_after_transaction", + "incoming_rate", + "valuation_rate", + "company", + ] + + entries = frappe.get_all( + "Stock Ledger Entry", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc, posting_time desc", + ) + + return json.dumps({"success": True, "count": len(entries), "results": entries}) + except Exception as e: + frappe.log_error(f"ERPNext Get Stock Movements Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_get_stock_entries(**kwargs) -> str: + """List ERPNext stock entry documents with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + stock_entry_type = kwargs.get("stock_entry_type") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + docstatus = kwargs.get("docstatus") + limit = int(kwargs.get("limit", 20)) + + if stock_entry_type: + filters.append(["Stock Entry", "stock_entry_type", "=", stock_entry_type]) + if from_date: + filters.append(["Stock Entry", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Stock Entry", "posting_date", "<=", to_date]) + if docstatus is not None: + filters.append(["Stock Entry", "docstatus", "=", int(docstatus)]) + + fields = [ + "name", + "stock_entry_type", + "posting_date", + "company", + "docstatus", + ] + + entries = frappe.get_all( + "Stock Entry", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + for entry in entries: + entry["docstatus_label"] = _docstatus_label(entry.get("docstatus")) + + return json.dumps({"success": True, "count": len(entries), "results": entries}) + except Exception as e: + frappe.log_error(f"ERPNext Get Stock Entries Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_get_warehouses(**kwargs) -> str: + """List ERPNext warehouses with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [["Warehouse", "disabled", "=", int(kwargs.get("disabled", 0))]] + company = kwargs.get("company") + warehouse_type = kwargs.get("warehouse_type") + limit = int(kwargs.get("limit", 50)) + + if company: + filters.append(["Warehouse", "company", "=", company]) + if warehouse_type: + filters.append(["Warehouse", "warehouse_type", "=", warehouse_type]) + + fields = [ + "name", + "warehouse_name", + "warehouse_type", + "company", + "parent_warehouse", + "disabled", + ] + + warehouses = frappe.get_all( + "Warehouse", + fields=fields, + filters=filters, + limit=limit, + order_by="warehouse_name asc", + ) + + return json.dumps({"success": True, "count": len(warehouses), "results": warehouses}) + except Exception as e: + frappe.log_error(f"ERPNext Get Warehouses Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_get_delivery_notes(**kwargs) -> str: + """List ERPNext delivery notes with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + customer = kwargs.get("customer") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + docstatus = kwargs.get("docstatus") + limit = int(kwargs.get("limit", 20)) + + if customer: + filters.append(["Delivery Note", "customer", "=", customer]) + if from_date: + filters.append(["Delivery Note", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Delivery Note", "posting_date", "<=", to_date]) + if docstatus is not None: + filters.append(["Delivery Note", "docstatus", "=", int(docstatus)]) + + fields = [ + "name", + "customer", + "customer_name", + "posting_date", + "status", + "docstatus", + ] + + notes = frappe.get_all( + "Delivery Note", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + for note in notes: + note["docstatus_label"] = _docstatus_label(note.get("docstatus")) + + return json.dumps({"success": True, "count": len(notes), "results": notes}) + except Exception as e: + frappe.log_error(f"ERPNext Get Delivery Notes Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_get_purchase_receipts(**kwargs) -> str: + """List ERPNext purchase receipts with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + supplier = kwargs.get("supplier") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + docstatus = kwargs.get("docstatus") + limit = int(kwargs.get("limit", 20)) + + if supplier: + filters.append(["Purchase Receipt", "supplier", "=", supplier]) + if from_date: + filters.append(["Purchase Receipt", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Purchase Receipt", "posting_date", "<=", to_date]) + if docstatus is not None: + filters.append(["Purchase Receipt", "docstatus", "=", int(docstatus)]) + + fields = [ + "name", + "supplier", + "supplier_name", + "posting_date", + "status", + "docstatus", + ] + + receipts = frappe.get_all( + "Purchase Receipt", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + for receipt in receipts: + receipt["docstatus_label"] = _docstatus_label(receipt.get("docstatus")) + + return json.dumps({"success": True, "count": len(receipts), "results": receipts}) + except Exception as e: + frappe.log_error(f"ERPNext Get Purchase Receipts Error: {e}", "ERPNext Tool") + return _error(str(e)) diff --git a/huf/ai/tools/erpnext_reports.py b/huf/ai/tools/erpnext_reports.py new file mode 100644 index 00000000..cb76f1d0 --- /dev/null +++ b/huf/ai/tools/erpnext_reports.py @@ -0,0 +1,417 @@ +""" +ERPNext integration tools for built-in script reports. +Uses frappe.desk.query_report.run – read-only analytical tools. +""" + +import json +import frappe + + +def _erpnext_installed(): + try: + return "erpnext" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}) + + +def _run_report(report_name, filters): + """Helper to run an ERPNext query report and return a serializable result.""" + from frappe.desk.query_report import run as run_report + + # Remove empty string values to avoid report errors + cleaned = {k: v for k, v in filters.items() if v != ""} + + result = run_report( + report_name=report_name, + filters=cleaned, + ignore_prepared_report=True, + ) + + # Serialize columns and rows safely + columns = [] + for col in result.get("columns", []): + if isinstance(col, dict): + columns.append({ + "label": col.get("label", ""), + "fieldname": col.get("fieldname", ""), + "fieldtype": col.get("fieldtype", ""), + "width": col.get("width", 0), + }) + elif isinstance(col, str): + columns.append({"label": col, "fieldname": col, "fieldtype": "Data"}) + else: + columns.append({"label": str(col), "fieldname": str(col), "fieldtype": "Data"}) + + rows = result.get("result", []) + serializable_rows = [] + for row in rows: + if isinstance(row, dict): + serializable_rows.append({k: (v if v is not None else "") for k, v in row.items()}) + elif hasattr(row, "__dict__"): + serializable_rows.append({k: (v if v is not None else "") for k, v in row.__dict__.items()}) + else: + serializable_rows.append({"value": str(row)}) + + return {"success": True, "results": serializable_rows, "columns": columns} + + +# --------------------------------------------------------------------------- +# Financial Statements +# --------------------------------------------------------------------------- + +def handle_balance_sheet(**kwargs) -> str: + """Run ERPNext Balance Sheet report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + company = kwargs.get("company") + if not company: + return _error("company is required") + + filters = { + "company": company, + "fiscal_year": kwargs.get("fiscal_year", ""), + "from_fiscal_year": kwargs.get("from_fiscal_year", ""), + "to_fiscal_year": kwargs.get("to_fiscal_year", ""), + "periodicity": kwargs.get("periodicity", ""), + "accumulated_values": int(kwargs.get("accumulated_values", 1)), + } + + return json.dumps(_run_report("Balance Sheet", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Balance Sheet Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +def handle_profit_and_loss(**kwargs) -> str: + """Run ERPNext Profit and Loss Statement report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + company = kwargs.get("company") + if not company: + return _error("company is required") + + filters = { + "company": company, + "fiscal_year": kwargs.get("fiscal_year", ""), + "periodicity": kwargs.get("periodicity", ""), + "from_date": kwargs.get("from_date", ""), + "to_date": kwargs.get("to_date", ""), + } + + return json.dumps(_run_report("Profit and Loss Statement", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Profit and Loss Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +def handle_trial_balance(**kwargs) -> str: + """Run ERPNext Trial Balance report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + company = kwargs.get("company") + if not company: + return _error("company is required") + + filters = { + "company": company, + "from_date": kwargs.get("from_date", ""), + "to_date": kwargs.get("to_date", ""), + "show_zero_values": int(kwargs.get("show_zero_values", 0)), + } + + return json.dumps(_run_report("Trial Balance", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Trial Balance Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Accounts Reports +# --------------------------------------------------------------------------- + +def handle_general_ledger(**kwargs) -> str: + """Run ERPNext General Ledger report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + company = kwargs.get("company") + if not company: + return _error("company is required") + + filters = { + "company": company, + "from_date": kwargs.get("from_date", ""), + "to_date": kwargs.get("to_date", ""), + "account": kwargs.get("account", ""), + "party_type": kwargs.get("party_type", ""), + "party": kwargs.get("party", ""), + "voucher_no": kwargs.get("voucher_no", ""), + "group_by": kwargs.get("group_by", ""), + } + + result = _run_report("General Ledger", filters) + rows = result.get("results", []) + if len(rows) > int(kwargs.get("limit", 500)): + rows = rows[: int(kwargs.get("limit", 500))] + result["results"] = rows + result["truncated"] = True + + return json.dumps(result) + except Exception as e: + frappe.log_error(f"ERPNext General Ledger Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +def handle_accounts_receivable(**kwargs) -> str: + """Run ERPNext Accounts Receivable report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + company = kwargs.get("company") + if not company: + return _error("company is required") + + filters = { + "company": company, + "report_date": kwargs.get("report_date", ""), + "ageing_based_on": kwargs.get("ageing_based_on", "Due Date"), + "range1": int(kwargs.get("range1", 30)), + "range2": int(kwargs.get("range2", 60)), + "range3": int(kwargs.get("range3", 90)), + "customer": kwargs.get("customer", ""), + "payment_terms_template": kwargs.get("payment_terms_template", ""), + } + + return json.dumps(_run_report("Accounts Receivable", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Accounts Receivable Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +def handle_accounts_payable(**kwargs) -> str: + """Run ERPNext Accounts Payable report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + company = kwargs.get("company") + if not company: + return _error("company is required") + + filters = { + "company": company, + "report_date": kwargs.get("report_date", ""), + "ageing_based_on": kwargs.get("ageing_based_on", "Due Date"), + "range1": int(kwargs.get("range1", 30)), + "range2": int(kwargs.get("range2", 60)), + "range3": int(kwargs.get("range3", 90)), + "supplier": kwargs.get("supplier", ""), + } + + return json.dumps(_run_report("Accounts Payable", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Accounts Payable Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +def handle_bank_reconciliation(**kwargs) -> str: + """Run ERPNext Bank Reconciliation Statement report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + company = kwargs.get("company") + account = kwargs.get("account") + if not company: + return _error("company is required") + if not account: + return _error("account is required") + + filters = { + "company": company, + "account": account, + "from_date": kwargs.get("from_date", ""), + "to_date": kwargs.get("to_date", ""), + "include_pos_transactions": int(kwargs.get("include_pos_transactions", 0)), + } + + return json.dumps(_run_report("Bank Reconciliation Statement", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Bank Reconciliation Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Sales Reports +# --------------------------------------------------------------------------- + +def handle_sales_register(**kwargs) -> str: + """Run ERPNext Sales Register report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + if not from_date: + return _error("from_date is required") + if not to_date: + return _error("to_date is required") + + filters = { + "company": kwargs.get("company", ""), + "from_date": from_date, + "to_date": to_date, + "customer": kwargs.get("customer", ""), + "item_code": kwargs.get("item_code", ""), + } + + return json.dumps(_run_report("Sales Register", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Sales Register Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +def handle_sales_order_analysis(**kwargs) -> str: + """Run ERPNext Sales Order Analysis report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = { + "company": kwargs.get("company", ""), + "from_date": kwargs.get("from_date", ""), + "to_date": kwargs.get("to_date", ""), + "customer": kwargs.get("customer", ""), + "item_code": kwargs.get("item_code", ""), + "status": kwargs.get("status", ""), + } + + return json.dumps(_run_report("Sales Order Analysis", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Sales Order Analysis Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +def handle_customer_acquisition(**kwargs) -> str: + """Run ERPNext Customer Acquisition and Loyalty report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = { + "company": kwargs.get("company", ""), + "from_date": kwargs.get("from_date", ""), + "to_date": kwargs.get("to_date", ""), + "customer_group": kwargs.get("customer_group", ""), + "territory": kwargs.get("territory", ""), + } + + return json.dumps(_run_report("Customer Acquisition and Loyalty", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Customer Acquisition Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Stock / Inventory Reports +# --------------------------------------------------------------------------- + +def handle_stock_balance_report(**kwargs) -> str: + """Run ERPNext Stock Balance report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = { + "company": kwargs.get("company", ""), + "from_date": kwargs.get("from_date", ""), + "to_date": kwargs.get("to_date", ""), + "item_code": kwargs.get("item_code", ""), + "warehouse": kwargs.get("warehouse", ""), + "item_group": kwargs.get("item_group", ""), + } + + return json.dumps(_run_report("Stock Balance", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Stock Balance Report Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +def handle_stock_ledger_report(**kwargs) -> str: + """Run ERPNext Stock Ledger report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + if not from_date: + return _error("from_date is required") + if not to_date: + return _error("to_date is required") + + filters = { + "company": kwargs.get("company", ""), + "from_date": from_date, + "to_date": to_date, + "item_code": kwargs.get("item_code", ""), + "warehouse": kwargs.get("warehouse", ""), + "voucher_no": kwargs.get("voucher_no", ""), + } + + return json.dumps(_run_report("Stock Ledger", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Stock Ledger Report Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +def handle_item_wise_sales(**kwargs) -> str: + """Run ERPNext Item-wise Sales Register report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = { + "company": kwargs.get("company", ""), + "from_date": kwargs.get("from_date", ""), + "to_date": kwargs.get("to_date", ""), + "item_code": kwargs.get("item_code", ""), + "customer": kwargs.get("customer", ""), + } + + return json.dumps(_run_report("Item-wise Sales Register", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Item-wise Sales Error: {e}", "ERPNext Report Tool") + return _error(str(e)) + + +def handle_gross_profit(**kwargs) -> str: + """Run ERPNext Gross Profit report.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = { + "company": kwargs.get("company", ""), + "from_date": kwargs.get("from_date", ""), + "to_date": kwargs.get("to_date", ""), + "group_by": kwargs.get("group_by", ""), + } + + return json.dumps(_run_report("Gross Profit", filters)) + except Exception as e: + frappe.log_error(f"ERPNext Gross Profit Error: {e}", "ERPNext Report Tool") + return _error(str(e)) diff --git a/huf/ai/tools/helpdesk.py b/huf/ai/tools/helpdesk.py index 2683022c..4e7dd525 100644 --- a/huf/ai/tools/helpdesk.py +++ b/huf/ai/tools/helpdesk.py @@ -35,6 +35,7 @@ def handle_get_tickets(**kwargs) -> str: team = kwargs.get("team") search = kwargs.get("search") limit = int(kwargs.get("limit", 20)) + offset = int(kwargs.get("offset", 0)) if status: filters["status"] = status @@ -73,6 +74,7 @@ def handle_get_tickets(**kwargs) -> str: filters=filters, or_filters=or_filters or None, limit=limit, + limit_start=offset, order_by="modified desc", ) @@ -126,9 +128,9 @@ def handle_create_ticket(**kwargs) -> str: doc.description = kwargs.get("description", "") doc.customer = kwargs.get("customer", "") doc.priority = kwargs.get("priority", "") - doc.ticket_type = kwargs.get("ticket_type", "") + doc.ticket_type = kwargs.get("ticket_type") or kwargs.get("type", "") doc.agent_group = kwargs.get("team", "") - doc.raised_by = kwargs.get("raised_by", frappe.session.user) + doc.raised_by = kwargs.get("raised_by") or kwargs.get("email", frappe.session.user) doc.contact = kwargs.get("contact", "") doc.insert(ignore_permissions=True) @@ -158,6 +160,10 @@ def handle_update_ticket(**kwargs) -> str: doc.priority = kwargs["priority"] if "team" in kwargs: doc.agent_group = kwargs["team"] + if "ticket_type" in kwargs: + doc.ticket_type = kwargs["ticket_type"] + if "type" in kwargs: + doc.ticket_type = kwargs["type"] if "description" in kwargs: doc.description = kwargs["description"] if "resolution_details" in kwargs: @@ -249,10 +255,20 @@ def handle_get_teams(**kwargs) -> str: try: limit = int(kwargs.get("limit", 50)) + offset = int(kwargs.get("offset", 0)) + search = kwargs.get("search") + or_filters = [] + if search: + or_filters = [ + ["HD Team", "team_name", "like", f"%{search}%"], + ] + teams = frappe.get_all( "HD Team", - fields=["name", "team_name", "ignore_restrictions"], + fields=["name", "team_name", "ignore_restrictions", "assignment_rule"], + or_filters=or_filters or None, limit=limit, + limit_start=offset, order_by="modified desc", ) return json.dumps({"success": True, "count": len(teams), "results": teams}) diff --git a/huf/ai/tools/raven.py b/huf/ai/tools/raven.py index 3dc4e354..e16fdc60 100644 --- a/huf/ai/tools/raven.py +++ b/huf/ai/tools/raven.py @@ -121,9 +121,12 @@ def handle_list_channels(**kwargs) -> str: filters = {"is_archived": 0} channel_type = kwargs.get("channel_type") or kwargs.get("type") if channel_type: - filters["type"] = channel_type.capitalize() + ctype = channel_type.capitalize() + if ctype in {"Private", "Public", "Open"}: + filters["type"] = ctype limit = int(kwargs.get("limit", 50)) + offset = int(kwargs.get("offset", 0)) channels = frappe.get_all( "Raven Channel", @@ -140,6 +143,7 @@ def handle_list_channels(**kwargs) -> str: ], filters=filters, limit=limit, + limit_start=offset, order_by="modified desc", ) @@ -161,10 +165,15 @@ def handle_get_channel_members(**kwargs) -> str: if not channel_id: return _error("channel_id or channel_name is required and must exist") + limit = int(kwargs.get("limit", 100)) + offset = int(kwargs.get("offset", 0)) + members = frappe.get_all( "Raven Channel Member", filters={"channel_id": channel_id}, fields=["name", "user_id", "is_admin", "last_visit", "allow_notifications"], + limit=limit, + limit_start=offset, order_by="creation asc", ) @@ -244,6 +253,7 @@ def handle_search_messages(**kwargs) -> str: kwargs.get("channel_id"), kwargs.get("channel_name") ) limit = int(kwargs.get("limit", 50)) + offset = int(kwargs.get("offset", 0)) filters = {"text": ["like", f"%{query}%"]} if channel_id: @@ -265,6 +275,7 @@ def handle_search_messages(**kwargs) -> str: ], filters=filters, limit=limit, + limit_start=offset, order_by="creation desc", ) From c9b955cae63dea13d69cbcdbea82c680a0fe842c Mon Sep 17 00:00:00 2001 From: esafwan Date: Fri, 29 May 2026 02:28:41 +0400 Subject: [PATCH 03/12] docs: add memory architecture zero-to-hero, phase plan, and updated RFC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new documentation files covering the full memory architecture: - docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md — Updated RFC reflecting current state: three live backends (sqlite_fts, sqlite_vec, chroma), Phase 1 implemented via PR #275, phases 2–5 defined with learning profiles and learning agents - docs/memory/zero-to-hero.md — Full onboarding doc capturing intellectual provenance (Agno/Hindsight/Mem0 references), how existing knowledge backends work, the three-layer architecture, what exists today vs what is planned, key files, design decisions and their reasons, glossary - docs/memory/phase-plan.md — Per-phase delivery plan with definition of done, file-level targets, cross-cutting concerns (security, testing, backward compat) --- docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md | 214 ++++++++++++ docs/memory/phase-plan.md | 313 +++++++++++++++++ docs/memory/zero-to-hero.md | 324 ++++++++++++++++++ .../2026-05-29-memory-architecture-design.md | 31 ++ 4 files changed, 882 insertions(+) create mode 100644 docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md create mode 100644 docs/memory/phase-plan.md create mode 100644 docs/memory/zero-to-hero.md create mode 100644 docs/superpowers/specs/2026-05-29-memory-architecture-design.md diff --git a/docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md b/docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md new file mode 100644 index 00000000..9f1c5c3b --- /dev/null +++ b/docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md @@ -0,0 +1,214 @@ +# RFC: Scoped Memory, Data Management, and Knowledge Bridge for HUF + +**Status:** Active — Phase 1 implemented, Phases 2–5 planned +**Type:** Architecture RFC (living document) +**Target branch:** `develop` + +> **Update log:** +> - 2026-05-27: Initial RFC published (PR #274) +> - 2026-05-29: Updated to reflect PR #275 implementation, multiple live backends, updated phase plan, learning profile design + +**Related:** +- PR #274 — This RFC (docs-only) +- PR #275 — Phase 1 implementation (Memory Record DocType, memory tools, projection pipeline) +- PR #178 — Agno-style knowledge/vector architecture RFC *(future, not yet merged)* +- PR #225 — Hindsight long-term memory evaluation *(future, not yet merged)* +- [zero-to-hero.md](./memory/zero-to-hero.md) — Full onboarding guide for new contributors +- [phase-plan.md](./memory/phase-plan.md) — Detailed per-phase implementation plan + +--- + +## 1. Purpose + +HUF needs a memory layer. Agents have no way to remember what they've learned across conversations. Every session is a blank slate. + +The gap is not in static knowledge (we have Knowledge Sources with FTS/vector/RAG). The gap is in **learned, scoped, evolving data** — preferences, decisions, patterns, research — that starts in one conversation and needs to persist, be governed, and optionally become searchable. + +This RFC defines the architecture and phased delivery plan for that layer. + +--- + +## 2. The three-layer model + +``` +Conversation Data (temporary) + ↓ manual save or policy extraction +Memory Record (canonical, scoped, governed) + ↓ explicit or policy-driven promotion +Knowledge Source (indexed, searchable) + └── sqlite_fts | sqlite_vec | chroma | future: pg_vector... +``` + +**Core principle:** Memory Record is canonical. Knowledge is an optional indexed projection. The memory layer does not know about backends — it targets a Knowledge Source, and the Knowledge Source owns the backend type. Adding new backends requires no changes to memory tools. + +--- + +## 3. What exists today (as of 2026-05-29) + +### Knowledge backends (live) + +Three backends exist, all implementing a common `KnowledgeBackend` ABC: + +| Backend | Type | When to use | Dependencies | +|---------|------|-------------|--------------| +| `sqlite_fts` | Keyword (BM25) | Always available | None | +| `sqlite_vec` | Vector (semantic) | Semantic similarity | pysqlite3-binary + sqlite-vec | +| `chroma` | Vector (semantic) | Separate vector store, optionally server-mode | chromadb + llama-index | + +Selected per `Knowledge Source` via `knowledge_type` field. Adding new backends (pg_vector, Qdrant, Weaviate) means implementing `KnowledgeBackend` and registering it — no memory layer changes needed. + +### Memory Record (PR #275) + +DocType with full schema: scopes, visibility, lifecycle, projection fields, quality signals (confidence, importance_score), tags, TTL, supersession. + +Scopes: Conversation / User / Role / Agent / Site / Global +Record types: Fact / Preference / Decision / Pattern / Research / Instruction + +Projection pipeline: +- Memory Record → formatted text → Knowledge Input (Text) → Knowledge Source queue +- Projection status: `Not Indexed → Queued → Projected → Error / Removed` +- "Projected" = handed to Knowledge Input pipeline. Actual indexing is async in Knowledge Input. + +Permission model: +- Desk access: System Manager + Huf Manager only +- User/agent access: through tool-level scope enforcement only +- Role/Site/Global write: Managers only +- Knowledge promotion: Managers only + +### Memory Policy (PR #275) + +Config shell — full schema, no runtime enforcement yet. + +Fields cover: capture mode, approval rules, retrieval injection mode, token budget, auto-promote thresholds, allowed record types, lifecycle TTL. + +Runtime enforcement begins in Phase 2. + +### Memory tools (PR #275) + +Five whitelisted handlers, agent-callable via native tool types: +- `save_memory_record` — scoped write with permission enforcement +- `get_memory_record` — scoped read with permission enforcement +- `search_memory_records` — multi-scope search, query filter, limit cap (max 50) +- `archive_memory_record` — sets status to Archived +- `promote_memory_to_knowledge` — manager-only, queues projection + +--- + +## 4. Terminology + +| Term | Meaning | +|------|---------| +| Conversation Data | Temporary working state for one session | +| Memory Record | Canonical scoped learned fact/preference/decision/pattern | +| Memory Policy | Config governing capture, retrieval, injection, promotion | +| Knowledge Projection | Act of converting Memory Record → Knowledge Input → indexed | +| Knowledge Source | Indexed searchable store with a pluggable backend | +| Learning Trigger | When post-run extraction fires (end of conversation, every N turns, manual) | +| Learning Agent | An Agent configured to read transcripts and extract Memory Records | +| Learning Profile | Named Memory Policy preset (conservative, conversational, research, operational) | +| Agno-direction | Pluggable reader/store/retrieval pattern (backends exist, hybrid search is Phase 5) | +| Hindsight-direction | Post-run extract/retain/reflect pattern (Phase 3) | + +--- + +## 5. Scope matrix + +| Scope | Writer | Reader | scope_key | +|-------|--------|--------|-----------| +| Conversation | Any authenticated user | Same conversation | conversation docname | +| User | That user only | That user only | frappe.session.user | +| Role | Managers only | Users with that role (visibility=Shared with Role) | role name | +| Agent | Managers (or policy-allowed agent) | Agent with matching name | agent docname | +| Site | Managers only | Everyone (visibility=Site) | frappe.local.site | +| Global | Managers only | Everyone (visibility=Global) | "global" | + +--- + +## 6. Reference architecture and influences + +### Agno (phidata) framework + +Agno separates agents into memory (short-term), knowledge (indexed content), and storage (long-term runs). Its knowledge system uses pluggable readers and vector stores. + +**What HUF adopted:** Pluggable backend ABC, clean separation of ingestion from retrieval, agent-linked knowledge sources. + +**What HUF does differently:** Frappe DocType ownership, multi-tenancy via scopes, permission governance. + +### Hindsight memory pattern + +Post-run memory consolidation with retain/recall/reflect operations. A reflection agent reads conversation transcripts and extracts durable learnings. + +**What HUF adopted:** Learning agent delegation, draft-first extraction, approval workflow, turn-based and session-end triggers. + +**What HUF defers:** Periodic reflection/consolidation across many memories (Phase 3+), contradiction detection, automatic supersession. + +### Mem0 / MemGPT + +Structured memory schemas with episodic/semantic/procedural types, importance scoring, visibility controls. + +**What HUF adopted:** Record type taxonomy, importance_score + confidence fields, visibility model. + +--- + +## 7. Implementation phases + +For full details, see [phase-plan.md](./memory/phase-plan.md). + +### Phase 0 — Alignment ✅ + +RFC, terminology, architecture decisions documented. + +### Phase 1 — Canonical Memory Record + Tools ✅ (PR #275) + +Memory Record DocType, Memory Policy (config shell), 5 tool handlers, projection pipeline, native tool wiring, manager-only Desk access. + +### Phase 2 — Policy Enforcement: Inject + Auto-promote + +Agent-linked memory policy, site-wide default policy, policy resolver. Runtime enforcement of inject_mode, token_budget, auto-promote thresholds. + +### Phase 3 — Learning: Post-run Extraction + +Memory Policy learning section (trigger, turns, learning_agent, approval). Background extraction job. Learning agent delegation. Draft-first safety. + +### Phase 4 — Learning Profiles + Learning Agent Formalization + +Named presets (Minimal, Conversational, Research, Operational). Learning Agent role type. Sensible defaults for agents without explicit policy. + +### Phase 5 — Retrieval Upgrades: Hybrid Search + Metadata Filters + +Hybrid FTS + vector scoring per Knowledge Source. Metadata filters (scope, tag, record_type). Chunk cleanup on Knowledge Input deletion. Abstract `supports_metadata_filters()` and `supports_hybrid_search()` on backends. + +--- + +## 8. Data quality and safety rules + +1. Raw conversation fragments should not automatically become long-term knowledge. +2. Derived records carry provenance (source_type, run, conversation). +3. Low-confidence records should remain Draft unless explicitly activated. +4. User-scoped memory must never appear in Role/Site/Global retrieval. +5. Role/Site/Global memory requires Manager-level write access. +6. Promotion to Knowledge is reversible (remove_knowledge_projection). +7. Indexes are rebuildable from canonical Memory Records. +8. Extracted records are Draft by default when approval_required = True. + +--- + +## 9. Non-goals (permanent) + +- Do not replace existing Knowledge Sources or Agent Knowledge semantics. +- Do not auto-index all conversation data. +- Do not make Hindsight a hard external dependency. +- Do not make vector DB dependencies mandatory (sqlite_fts always works without extras). +- Do not bypass Frappe permissions. +- Do not expose private user memory across role/site/global boundaries. + +--- + +## 10. Open questions + +1. Should memory injection be visible to users in the chat UI? (transparency) +2. Should users be able to view and delete their own User-scoped memory? +3. How should contradictory memory records be handled in Phase 3+? +4. Should Data Tables become eligible memory sources in a future phase? +5. What telemetry captures memory injection quality and extraction cost? +6. Should a formal Memory Record approval workflow (Frappe Workflow) be added in Phase 2? diff --git a/docs/memory/phase-plan.md b/docs/memory/phase-plan.md new file mode 100644 index 00000000..b795fc14 --- /dev/null +++ b/docs/memory/phase-plan.md @@ -0,0 +1,313 @@ +# HUF Memory Architecture: Implementation Phase Plan + +> **Status:** Active — Phase 1 in progress (PR #275) +> **Last updated:** 2026-05-29 +> **Related:** [zero-to-hero.md](./zero-to-hero.md) | [RFC](../SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md) + +--- + +## Overview + +This document defines the phased delivery plan for HUF's memory and learning architecture. Each phase is independently mergeable and leaves the system in a stable, useful state. + +``` +Phase 0 → Architecture alignment (done) +Phase 1 → Canonical Memory Record + tools (PR #275, in progress) +Phase 2 → Policy enforcement: inject + auto-promote +Phase 3 → Learning: post-run extraction +Phase 4 → Learning profiles + learning agent formalization +Phase 5 → Retrieval upgrades: hybrid search, metadata filters +``` + +--- + +## Phase 0 — Architecture Alignment ✅ Done + +**Branch:** `docs/scoped-memory-knowledge-bridge` | **PR:** #274 + +**Deliverables:** +- RFC defining the three-layer model: Conversation Data → Memory Record → Knowledge Source +- Terminology alignment: Memory Record vs Knowledge Input vs Knowledge Source vs Learning +- Phase roadmap (superseded by this document) + +**Status:** Merged to `docs/` branch. RFC lives at `docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md`. + +--- + +## Phase 1 — Canonical Memory Record + Tools 🔄 In Progress + +**Branch:** `feature/scoped-memory-core` | **PR:** #275 + +### Goal + +Ship a safe, internally consistent MVP: a governed Memory Record store with tool access for agents, and a working pipeline to promote records to Knowledge. + +### What is included + +**DocTypes:** +- `Memory Record` — full schema: scopes, visibility, lifecycle, projection fields, quality signals +- `Memory Policy` — config shell for future enforcement. Schema complete, no runtime enforcement yet. + +**Backend tools (whitelisted, agent-callable):** +- `save_memory_record` — scoped write with permission enforcement +- `get_memory_record` — scoped read with permission enforcement +- `search_memory_records` — multi-scope search, query filtering, limit cap +- `archive_memory_record` — sets status to Archived, checks both read + write permission +- `promote_memory_to_knowledge` — manager-only, queues projection to Knowledge Input + +**Knowledge projection pipeline:** +- Memory Record → formatted text → Knowledge Input (input_type=Text) → Knowledge Source queue +- Projection status: `Not Indexed → Queued → Projected → Error / Removed` +- `Projected` means Memory Record has been handed to Knowledge Input pipeline +- Actual indexing status is owned by Knowledge Input (Pending → Processing → Indexed → Error) +- Re-projection on `summary_text` or `data_json` change updates existing Knowledge Input rather than creating duplicates + +**Permission model:** +- Desk access to Memory Record: System Manager and Huf Manager only +- User/agent access: through tool-level scope/visibility filtering only +- Normal users: can write Conversation and their own User memory +- Managers: can write any scope, promote to knowledge + +**Native tool wiring:** +- 5 new types in `agent_tool_function.json`: Save Memory Record, Search Memory Records, Get Memory Record, Archive Memory Record, Promote Memory to Knowledge +- Each type maps to the corresponding handler in `huf/ai/memory_tools.py` + +### What is explicitly NOT included + +- Memory Policy runtime enforcement (config shell only) +- Automatic memory capture from runs +- Frontend memory tab or UI (Desk only, manager-visible) +- New vector DB logic +- Full chunk cleanup on Knowledge Input deletion (to be addressed in Phase 5) +- Hindsight-style retain/recall/reflect + +### Definition of done + +- [ ] `python -m py_compile` passes on all three new Python files +- [ ] `bench migrate` applies cleanly +- [ ] Memory Record can be created, activated, promoted to Knowledge +- [ ] Projection status shows `Projected` (not `Indexed`) after queuing +- [ ] Normal user cannot create Role/Site/Global memory via tools +- [ ] Manager can promote memory to an existing Knowledge Source +- [ ] Agent Tool Function can use Save Memory Record and Search Memory Records types + +--- + +## Phase 2 — Policy Enforcement: Inject + Auto-promote + +**Depends on:** Phase 1 merged + +### Goal + +Make Memory Policy do something at runtime. Focus on the two highest-value enforcement paths: injecting memory into agent context before a run, and auto-promoting records that meet quality thresholds. + +### What is included + +**Agent-level policy linking:** +- Add `memory_policy` Link field to Agent DocType +- Add `default_memory_policy` Link field to Agent Settings (singleton) for site-wide fallback +- Policy resolver: `resolve_memory_policy(agent_name)` → Agent Policy → Site Default → None + +**New module: `huf/ai/memory_policy_resolver.py`** +- `resolve_memory_policy(agent_name)` — returns the effective MemoryPolicy doc or None +- `get_injectable_memory(agent_name, conversation_id, policy)` — returns list of Memory Records within token budget +- `build_memory_context_block(records, policy)` — formats records for system prompt injection + +**Hook into agent_integration.py:** +- Before run: if `inject_mode != "None"` → prepend memory context block to system prompt +- After run: if `auto_promote_to_knowledge` → check records meeting min_confidence + min_importance → queue projection + +**Injection modes:** +- `None` — no injection (default) +- `Append to System Prompt` — memory records added to system prompt as a structured block +- `Tool Available` — inject nothing, but ensure `search_memory_records` tool is available to the agent + +**Token budget enforcement:** +- Records sorted by `importance_score desc, modified desc` +- Trimmed to fit within `token_budget` (estimated at 4 chars/token) +- If budget exceeded, lower-importance records are dropped silently + +**Auto-promote rule:** +- Background job checks new/updated Memory Records for the policy +- If `promote_to_knowledge = False` and record meets `promotion_min_confidence` + `promotion_min_importance`, set `promote_to_knowledge = 1` and queue projection + +### What is explicitly NOT included + +- Capture-side enforcement (auto-extraction from runs is Phase 3) +- `allow_role_scope_write` enforcement (manual manager override only for now) + +--- + +## Phase 3 — Learning: Post-run Extraction + +**Depends on:** Phase 2 merged + +### Goal + +Let Memory Policy control when and how the system extracts Memory Records from completed agent runs. Optionally delegate extraction to a dedicated learning agent. + +### What is included + +**Memory Policy — new Learning section fields:** +- `learning_enabled` (Check) +- `learning_trigger` (Select: Manual | End of Conversation | Every N Turns) +- `turns_per_extraction` (Int — used when trigger = Every N Turns) +- `learning_agent` (Link → Agent — optional, delegates extraction) +- `extracted_record_default_status` (Select: Draft | Active) +- `extraction_model` (Data — optional model override for built-in extraction) + +**New module: `huf/ai/memory_extractor.py`** +- `extract_memories_from_run(agent_run_id, memory_policy_name)` — main entry point +- Assembles conversation transcript from Agent Messages +- If `learning_agent` set: routes to that agent via `run_agent_sync()`, parses structured output +- If not: calls built-in extraction prompt against base model +- Saves extracted records with source_type = "Agent Run", run = agent_run_id + +**Trigger hooks:** +- End of Conversation: hook on Agent Conversation `on_update` when status changes to Closed/Complete +- Every N Turns: hook on Agent Message `after_insert`, count turns, fire when threshold reached +- Manual: whitelist endpoint `trigger_memory_extraction(agent_run_id)` for explicit calls + +**Extraction output format:** +- Learning agent / extraction prompt returns JSON list of memory record drafts +- Each includes: title, summary_text, record_type, confidence, importance_score, tags +- Scope defaults to Conversation for raw extractions; manager can promote later + +**Draft-first safety:** +- Default status controlled by `extracted_record_default_status` +- If `approval_required = True`, always Draft regardless of setting +- Desk review queue for Draft records: filter Memory Records by status=Draft, source_type=Agent Run + +### Built-in extraction prompt + +The default extraction prompt (when no learning_agent is set) will: +1. Receive the conversation transcript +2. Identify facts, preferences, decisions, patterns +3. Output structured JSON matching Memory Record fields +4. Not invent information not present in the transcript + +--- + +## Phase 4 — Learning Profiles + Learning Agent Formalization + +**Depends on:** Phase 3 merged + +### Goal + +Provide named configuration presets (Learning Profiles) so agents can adopt a sensible memory behavior without hand-crafting a Memory Policy from scratch. Formalize the Learning Agent pattern. + +### What is included + +**Learning Profiles (Memory Policy presets):** +Built-in presets seeded at install time: + +| Profile | capture_mode | inject_mode | approval_required | learning_trigger | +|---------|-------------|-------------|-------------------|-----------------| +| Minimal | Manual | None | Yes | Manual | +| Conversational | Auto | Append to System | No | End of Conversation | +| Research | Both | Tool Available | Yes | End of Conversation | +| Operational | Auto | Append to System | No | Every N Turns (5) | + +- Profiles are regular Memory Policy docs with `is_preset = True` +- Agents can link to a preset directly or clone it for customization +- Site admins can define additional custom presets + +**Learning Agent pattern:** +- `Agent` DocType gains `agent_role` field (Select: General | Learning | Orchestrator | ...) +- A Learning Agent has `agent_role = Learning` and a specialized system prompt +- Memory Policy's `learning_agent` field filtered to agents with `agent_role = Learning` +- Preset learning agents provided for common extraction styles: conservative, liberal, preferences-focused + +**Agent Settings:** +- `default_learning_profile` — fallback profile for agents without an explicit memory_policy link + +--- + +## Phase 5 — Retrieval Upgrades: Hybrid Search + Metadata Filters + +**Depends on:** Phase 2 merged (earlier phases are independent of this) + +### Goal + +Improve retrieval quality for memory-projected knowledge and existing Knowledge Sources. Enable hybrid FTS + vector scoring, metadata filtering by scope/tag/record_type, and prepare the backend for additional vector stores. + +### What is included + +**Hybrid search (per Knowledge Source):** +- Knowledge Sources with `knowledge_type = sqlite_fts` or `sqlite_vec` can opt-in to hybrid mode +- Hybrid mode runs both FTS and vector search, combines scores (reciprocal rank fusion) +- New `search_mode` field on Knowledge Source: FTS | Vector | Hybrid +- Hybrid requires embedding to be configured + +**Metadata filters in retrieval:** +- Knowledge Input gains optional `metadata_json` field for tags, scope_type, record_type +- During projection, Memory Record tags + scope_type + record_type written to Knowledge Input metadata +- `knowledge_search()` accepts `metadata_filters` dict to narrow results +- Example: `{"record_type": "Preference", "tags": "hospitality"}` → only matching chunks returned + +**Memory-specific search helper:** +- `search_memory_knowledge(query, agent_name, filters)` — searches Knowledge Sources containing projected memory +- Returns results attributed to source Memory Record (via knowledge_input → memory record backlink) + +**New backend scaffolding:** +- Abstract `supports_metadata_filters()` method on `KnowledgeBackend` +- Abstract `supports_hybrid_search()` method +- Concrete backends implement or return False +- Future backends (pg_vector, Qdrant, Weaviate) can implement both + +**Chunk cleanup on Knowledge Input deletion:** +- `KnowledgeInput.on_trash()` calls `backend.delete_chunks(input_id)` before deletion +- Ensures removing a projected Memory Record actually removes indexed content + +--- + +## Cross-cutting concerns + +### Security model (all phases) + +- Memory Record Desk access: Managers only +- Tool-level access: scoped per user/role/agent context +- Wider scopes (Role, Site, Global): Managers only +- Knowledge promotion: Managers only +- Extracted draft records: visible to managers for review before activation +- User-scoped memory: never leaks to role/site/global search + +### Testing strategy + +**Phase 1:** +- Controller validation tests (scope key, status, projection settings) +- Tool permission tests (who can write which scope) +- Projection lifecycle tests (status = Projected, re-projection updates existing KI) + +**Phase 2:** +- Policy resolver tests (agent policy vs site default vs None) +- Injection formatting tests (token budget trimming) +- Auto-promote threshold tests + +**Phase 3:** +- Extraction trigger tests (end of conversation, every N turns) +- Learning agent delegation tests (mock agent output → memory records) +- Draft/active status based on approval_required + +**Phase 5:** +- Hybrid search scoring tests +- Metadata filter tests per backend +- Chunk cleanup verification + +### Backward compatibility + +- Phase 1 adds new DocTypes — no changes to existing DocTypes +- Phase 2 adds optional fields to Agent and Agent Settings — no breaking changes +- Phase 3 adds optional fields to Memory Policy — no breaking changes +- Phase 5 adds optional fields to Knowledge Source and Knowledge Input — no breaking changes +- At no phase is existing Knowledge Source or Agent Tool Function behavior changed without opt-in + +--- + +## Open questions + +1. Should memory injection be visible to the user in the chat UI? (Attribution / transparency) +2. Should users be able to view and delete their own User-scoped memory via the chat UI? +3. How should contradictory memory records be handled in Phase 3+ (flag only, or attempt resolution)? +4. Should Data Tables be eligible as memory sources in a future phase? +5. What telemetry should be captured for memory injection quality and extraction cost? diff --git a/docs/memory/zero-to-hero.md b/docs/memory/zero-to-hero.md new file mode 100644 index 00000000..1fd88140 --- /dev/null +++ b/docs/memory/zero-to-hero.md @@ -0,0 +1,324 @@ +# HUF Memory Architecture: Zero to Hero + +> **Who this is for:** Anyone new to the memory/knowledge area of HUF — engineers, contributors, or agents reading this cold. This document captures the full intellectual journey: where the ideas came from, what was tried, what was decided, and why. Read this before touching any memory-related code. + +--- + +## 1. The problem we are solving + +HUF started as a conversational AI platform. Agents talk to users, run tools, and produce results. But every conversation was a blank slate. Agents had no memory of what they'd learned, no way to build up preferences or patterns over time, and no way to carry useful facts from one session to the next. + +This became a real limitation: + +- A user tells an agent their preferences in one conversation. The agent has forgotten them in the next. +- An agent learns a reliable routing pattern after many runs. That learning disappears. +- Research done in one session cannot be made available as searchable knowledge for other agents. +- There is no way to say "this fact, learned from a conversation, should be considered authoritative at the site level." + +The gap was: **HUF had Knowledge Sources (static documents, PDFs, URLs) and conversation-local working data, but nothing in between** — no layer for learned, scoped, evolving data that could optionally become searchable. + +--- + +## 2. The reference systems that shaped the design + +Before arriving at the current architecture, several external systems were studied carefully. Understanding these is essential for understanding why HUF's design looks the way it does. + +### 2.1 Agno (formerly phidata) + +Agno is an open-source Python framework for building multi-modal AI agents. It has a clean separation between: + +- **Agent memory**: short-term per-session state +- **Agent knowledge**: structured, indexed, searchable content (PDFs, URLs, tables, text) +- **Agent storage**: long-term persistence of runs and sessions + +Agno's knowledge system uses a `Knowledge` class with pluggable readers (PDFReader, URLReader, etc.) and vector stores (pgvector, Qdrant, Pinecone, LanceDB, etc.). It separates the *reader* (how you get text from a source) from the *store* (how you index and retrieve it). + +**What HUF borrowed from Agno:** +- The concept of pluggable knowledge backends (sqlite_fts, sqlite_vec, chroma — all implement a common `KnowledgeBackend` ABC) +- The idea that agents should be able to search knowledge before responding +- The pattern of separating ingestion from retrieval + +**What HUF did differently:** +- HUF wraps this in Frappe DocTypes (Knowledge Source, Knowledge Input) so it integrates with the rest of the platform's permissions, workflows, and UI +- HUF has a stronger multi-tenancy and scoping requirement (User, Role, Agent, Site, Global) + +### 2.2 Hindsight (memory consolidation pattern) + +Hindsight is a research-inspired design pattern for long-term agent memory. The core idea is three operations: + +- **retain**: extract and save something worth remembering from a conversation +- **recall**: retrieve relevant memory when starting a new conversation +- **reflect**: periodically consolidate, deduplicate, and upgrade memory quality + +Hindsight-style systems typically run as a background process after each conversation. A second LLM call (the "reflection agent") reads the transcript and decides what to save. + +**What HUF borrowed from Hindsight:** +- The idea of a dedicated "learning agent" that reads transcripts and extracts memory +- The concept of a learning trigger (end of conversation, every N turns) +- The draft → review → active lifecycle for extracted memories + +**What HUF is NOT doing (yet):** +- Hindsight's reflect step (periodic consolidation across many memories) is not implemented +- Automatic contradiction detection is not implemented +- Memory decay and supersession is manual, not automatic + +### 2.3 Mem0 / MemGPT patterns + +These systems maintain a dedicated memory layer that agents read from and write to during a conversation, with a structured schema for different memory types (episodic, semantic, procedural). + +**What HUF borrowed:** +- The scoped record type model (Fact, Preference, Decision, Pattern, Research, Instruction) +- The importance score + confidence fields for quality filtering +- The visibility model (Private, Shared with Role, Site, Global) + +--- + +## 3. How HUF's existing knowledge system works + +Before memory makes sense, you need to understand knowledge. Here is the actual pipeline: + +``` +Knowledge Source (DocType) +├── knowledge_type: sqlite_fts | sqlite_vec | chroma +├── embedding_model (for vector backends) +└── Knowledge Inputs [] + ├── input_type: Text | File | URL + ├── status: Pending | Processing | Indexed | Error + └── text / file / url content + +Indexing pipeline (huf/ai/knowledge/indexer.py): + Knowledge Input → extract text → chunk → embed (if vector) → store in backend + +Retrieval pipeline (huf/ai/knowledge/retriever.py): + query → embed query → search backend → return ChunkResult[] +``` + +**Three backends exist today, all implementing `KnowledgeBackend` ABC:** + +| Backend | Type | When to use | Dependencies | +|---------|------|-------------|--------------| +| `sqlite_fts` | Keyword (BM25) | Always available, no GPU needed | None | +| `sqlite_vec` | Vector (semantic) | When you need semantic similarity | pysqlite3-binary + sqlite-vec | +| `chroma` | Vector (semantic) | When you want a separate vector store, optionally server-mode | chromadb + llama-index | + +**Adding a new backend** (e.g., pg_vector) means: +1. Implement `KnowledgeBackend` ABC in `huf/ai/knowledge/backends/` +2. Register it in `get_backend()` in `__init__.py` +3. Add the option to Knowledge Source's `knowledge_type` Select field + +The memory layer **never needs to change** when backends are added or removed. + +--- + +## 4. The memory architecture + +### 4.1 The three-layer model + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Conversation Data (temporary, per-session) │ +│ → selected items, form values, current state, agent working memory │ +│ → lives in Agent Conversation / run context only │ +└────────────────────────────┬────────────────────────────────────────┘ + │ manual save or policy-triggered extract + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Memory Record (canonical, scoped, governed) │ +│ → Fact, Preference, Decision, Pattern, Research, Instruction │ +│ → scoped: Conversation / User / Role / Agent / Site / Global │ +│ → governed by Memory Policy │ +└────────────────────────────┬────────────────────────────────────────┘ + │ promote_to_knowledge (explicit or auto) + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Knowledge Source (indexed, searchable) │ +│ → sqlite_fts | sqlite_vec | chroma | future: pg_vector... │ +│ → used by agents via mandatory/optional knowledge linking │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Key principle:** Memory Record does not know about backends. It targets a Knowledge Source. The Knowledge Source owns the backend type. This means new backends require zero changes to memory tools or Memory Policy. + +### 4.2 Memory Record scopes + +| Scope | Who can write | Who can read | scope_key value | +|-------|--------------|--------------|-----------------| +| Conversation | Any user (in that conversation) | Same conversation only | conversation docname | +| User | That user only | That user only | frappe.session.user | +| Role | Managers only | Users with that role + visibility="Shared with Role" | role name | +| Agent | Managers only (or agent if allowed by policy) | Agent with matching name | agent docname | +| Site | Managers only | Everyone if visibility="Site" | frappe.local.site | +| Global | Managers only | Everyone if visibility="Global" | "global" | + +### 4.3 Memory Policy + +Memory Policy is the governance and behavior config layer. It sits between Memory Records and agent runtime. + +**What it configures (fields exist, enforcement is phased):** + +``` +Capture: + capture_mode: Manual | Auto (on_run_end) | Both + approval_required: bool + default_status: Draft | Active + allowed_record_types: [Fact, Preference, ...] + +Retrieval: + inject_mode: None | Append to System Prompt | Tool Available + max_records: int + token_budget: int + +Write controls: + allow_agent_write: bool + allow_user_scope_write: bool + allow_role_scope_write: bool (manager override) + +Projection: + auto_promote_to_knowledge: bool + knowledge_source: Link → Knowledge Source + promotion_min_confidence: float + promotion_min_importance: float + +Lifecycle: + ttl_days: int +``` + +Policy resolution at runtime: **Agent Policy → Site Default → built-in safe defaults.** + +--- + +## 5. The learning system + +### 5.1 What "learning" means here + +An agent "learns" when something worth remembering is extracted from a run or conversation and saved as a Memory Record for future use. This is different from fine-tuning the model. It is structured, auditable, and reversible. + +### 5.2 How extraction works (Phase 3) + +When a learning trigger fires: +1. The agent run transcript is assembled +2. If `learning_agent` is set on the Memory Policy, that agent is called with the transcript +3. If not, a built-in extraction prompt runs against the same base model +4. Extracted facts/preferences/decisions are saved as Memory Records +5. Default status is `Draft` (if `approval_required`) or `Active` (if not) + +### 5.3 Learning triggers + +| Trigger | When it fires | +|---------|--------------| +| Manual | Only when explicitly called | +| End of Conversation | When conversation is closed / marked complete | +| Every N Turns | After every N agent turns in the conversation | + +### 5.4 Learning agent pattern + +A "learning agent" is just a regular HUF Agent with a specialized system prompt. It receives a conversation transcript and returns structured memory records. This means: +- You can use any model for extraction (not necessarily the same as the active agent) +- You can version and iterate the extraction prompt without touching the main agent +- You can inspect what the learning agent produces before it becomes Active + +--- + +## 6. What exists today vs what is planned + +### Today (after PR #275 is merged) + +| Capability | Status | +|-----------|--------| +| Memory Record DocType (full schema) | ✅ Done | +| Memory Policy DocType (schema + validation) | ✅ Done (config shell, no runtime enforcement) | +| 5 memory tool handlers | ✅ Done | +| Scoped permission enforcement in tools | ✅ Done | +| Memory → Knowledge Input projection | ✅ Done | +| Projection status tracking | ✅ Done (`Projected`, not `Indexed`) | +| Manager-only Desk access | ✅ Done | +| Native tool wiring in Agent Tool Function | ✅ Done | +| Memory Policy enforcement at runtime | ❌ Phase 2 | +| Agent-linked memory policy | ❌ Phase 2 | +| Auto-inject memory into agent context | ❌ Phase 2 | +| Post-run memory extraction | ❌ Phase 3 | +| Learning agent delegation | ❌ Phase 3 | +| Learning profiles (presets) | ❌ Phase 4 | +| Hybrid FTS + vector search for memory | ❌ Phase 5 | + +### Not in scope (ever, by design) + +- Automatic promotion of all conversation data to memory (too noisy) +- Fine-tuning or model weight updates +- Replacing Frappe permissions with custom auth +- Memory leaking across user/role/site boundaries + +--- + +## 7. Key files and where to look + +| What | Where | +|------|-------| +| Memory tool handlers (save/get/search/archive/promote) | `huf/ai/memory_tools.py` | +| Memory Record controller (validation, projection queue) | `huf/huf/doctype/memory_record/memory_record.py` | +| Memory Record schema | `huf/huf/doctype/memory_record/memory_record.json` | +| Memory Policy controller | `huf/huf/doctype/memory_policy/memory_policy.py` | +| Memory Policy schema | `huf/huf/doctype/memory_policy/memory_policy.json` | +| Knowledge backend abstraction | `huf/ai/knowledge/backends/__init__.py` | +| FTS backend | `huf/ai/knowledge/backends/sqlite_fts.py` | +| Vector backend | `huf/ai/knowledge/backends/sqlite_vec_backend.py` | +| ChromaDB backend | `huf/ai/knowledge/backends/chroma_backend.py` | +| Indexing pipeline | `huf/ai/knowledge/indexer.py` | +| Retrieval pipeline | `huf/ai/knowledge/retriever.py` | +| Knowledge Source controller | `huf/huf/doctype/knowledge_source/knowledge_source.py` | +| Phase plan | `docs/memory/phase-plan.md` | +| RFC (architecture decisions) | `docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md` | + +--- + +## 8. How to contribute + +**Adding a new knowledge backend:** +1. Implement `KnowledgeBackend` in `huf/ai/knowledge/backends/` +2. Register in `get_backend()` in `__init__.py` +3. Add option to Knowledge Source `knowledge_type` field +4. Add validation in `knowledge_source.py` if dependencies need checking + +**Adding a new memory scope or record type:** +- Scope types: add to `scope_type` Select field in `memory_record.json`, update `can_read()` and `can_write()` in `memory_tools.py`, update scope resolver in `resolved_key()` +- Record types: add to `record_type` Select field in `memory_record.json` (no code changes needed) + +**Implementing a new Memory Policy enforcement (Phase 2+):** +- The policy resolver logic will live in `huf/ai/memory_policy_resolver.py` (to be created) +- It should read the agent's linked policy or fall back to site default from Agent Settings +- Hook into `agent_integration.py` before and after agent runs + +**Writing a learning agent:** +- Create a regular Agent with a specialized system prompt for memory extraction +- The system prompt should instruct the agent to output structured memory records +- Link it as `learning_agent` in a Memory Policy + +--- + +## 9. Design decisions and their reasons + +| Decision | Why | +|---------|-----| +| Memory Record doesn't know about backends | Adding pg_vector shouldn't require touching memory tools | +| Memory Policy is config-shell-first | The schema needs to stabilize before enforcement. Wrong enforcement is worse than no enforcement. | +| Manager-only Desk access to Memory Records | Desk DocPerm can't enforce per-scope visibility rules. Tool-level access is the correct control path for users. | +| Projection status = "Projected" not "Indexed" | "Indexed" implies the knowledge pipeline has completed. It hasn't — Knowledge Input processing is async. "Projected" means we've handed it off. | +| Learning agent is a regular Agent, not special-cased | Reuses the entire Agent infrastructure. Can be versioned, tested, and swapped independently. | +| Draft default for extracted memory | Automatic extraction without human review is a data quality risk. Draft-first is safer. | + +--- + +## 10. Glossary + +| Term | Meaning | +|------|---------| +| **Memory Record** | A canonical scoped fact, preference, decision, pattern, or research note. The source of truth for what an agent has learned. | +| **Memory Policy** | Config governing capture, retrieval, injection, and promotion rules for memory records. Linked to an Agent or set site-wide. | +| **Knowledge Source** | A HUF DocType representing an indexed, searchable knowledge store. Has a pluggable backend (FTS, vector, chroma). | +| **Knowledge Input** | A single item (text, file, URL) that gets indexed into a Knowledge Source. | +| **Knowledge Projection** | The act of converting a Memory Record into a Knowledge Input so it becomes searchable. Status = Projected means it has been handed to the Knowledge Input pipeline. | +| **Learning Agent** | A regular HUF Agent configured specifically to read transcripts and extract Memory Records. | +| **Scope** | The boundary within which a Memory Record is valid and visible. (Conversation / User / Role / Agent / Site / Global) | +| **Visibility** | Fine-grained access control within a scope. (Private / Shared with Role / Shared with Agent / Site / Global) | +| **Agno-direction** | Pluggable reader/store architecture for knowledge, inspired by the Agno (phidata) framework. | +| **Hindsight-direction** | Post-run memory extraction pattern with retain/recall/reflect semantics. | diff --git a/docs/superpowers/specs/2026-05-29-memory-architecture-design.md b/docs/superpowers/specs/2026-05-29-memory-architecture-design.md new file mode 100644 index 00000000..72f4c99d --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-memory-architecture-design.md @@ -0,0 +1,31 @@ +# Memory Architecture Design + +**Date:** 2026-05-29 +**Status:** Approved +**Outcome:** Phase plan and RFC updated. Phase 1 (PR #275) hardening next. + +--- + +## Decisions made + +### Backend coupling +Memory Record → Knowledge Source → Backend (loose). Memory tools never reference backend types. New backends (pg_vector, Qdrant) implemented as `KnowledgeBackend` subclasses. Zero memory layer changes needed. + +### Memory Policy activation +Phase 2: Agent-linked policy + site-wide default. Resolver chain: Agent Policy → Site Default → built-in safe defaults. Enforcement of inject_mode and token_budget first. + +### Learning model +Hybrid: tool-driven saves now (Phase 1), policy-triggered extraction in Phase 3. Granular triggers: End of Conversation, Every N Turns, Manual. Optional delegation to a Learning Agent (a regular HUF Agent with specialized prompt). Draft-first by default. + +### Output +- Updated `docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md` — corrected, reflects live backends + phases +- New `docs/memory/zero-to-hero.md` — full intellectual context doc for new contributors +- New `docs/memory/phase-plan.md` — per-phase delivery plan with definition of done + +--- + +## Documents produced + +- [RFC](../../SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md) +- [Zero to Hero](../../memory/zero-to-hero.md) +- [Phase Plan](../../memory/phase-plan.md) From cba94064017bddd8ac95bf196801f8dd3f8eec9c Mon Sep 17 00:00:00 2001 From: esafwan Date: Fri, 29 May 2026 02:31:10 +0400 Subject: [PATCH 04/12] Revert "docs: add memory architecture zero-to-hero, phase plan, and updated RFC" This reverts commit a2f6ac7896809ceacd93922c1af7db3de450d8a4. --- docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md | 214 ------------ docs/memory/phase-plan.md | 313 ----------------- docs/memory/zero-to-hero.md | 324 ------------------ .../2026-05-29-memory-architecture-design.md | 31 -- 4 files changed, 882 deletions(-) delete mode 100644 docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md delete mode 100644 docs/memory/phase-plan.md delete mode 100644 docs/memory/zero-to-hero.md delete mode 100644 docs/superpowers/specs/2026-05-29-memory-architecture-design.md diff --git a/docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md b/docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md deleted file mode 100644 index 9f1c5c3b..00000000 --- a/docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md +++ /dev/null @@ -1,214 +0,0 @@ -# RFC: Scoped Memory, Data Management, and Knowledge Bridge for HUF - -**Status:** Active — Phase 1 implemented, Phases 2–5 planned -**Type:** Architecture RFC (living document) -**Target branch:** `develop` - -> **Update log:** -> - 2026-05-27: Initial RFC published (PR #274) -> - 2026-05-29: Updated to reflect PR #275 implementation, multiple live backends, updated phase plan, learning profile design - -**Related:** -- PR #274 — This RFC (docs-only) -- PR #275 — Phase 1 implementation (Memory Record DocType, memory tools, projection pipeline) -- PR #178 — Agno-style knowledge/vector architecture RFC *(future, not yet merged)* -- PR #225 — Hindsight long-term memory evaluation *(future, not yet merged)* -- [zero-to-hero.md](./memory/zero-to-hero.md) — Full onboarding guide for new contributors -- [phase-plan.md](./memory/phase-plan.md) — Detailed per-phase implementation plan - ---- - -## 1. Purpose - -HUF needs a memory layer. Agents have no way to remember what they've learned across conversations. Every session is a blank slate. - -The gap is not in static knowledge (we have Knowledge Sources with FTS/vector/RAG). The gap is in **learned, scoped, evolving data** — preferences, decisions, patterns, research — that starts in one conversation and needs to persist, be governed, and optionally become searchable. - -This RFC defines the architecture and phased delivery plan for that layer. - ---- - -## 2. The three-layer model - -``` -Conversation Data (temporary) - ↓ manual save or policy extraction -Memory Record (canonical, scoped, governed) - ↓ explicit or policy-driven promotion -Knowledge Source (indexed, searchable) - └── sqlite_fts | sqlite_vec | chroma | future: pg_vector... -``` - -**Core principle:** Memory Record is canonical. Knowledge is an optional indexed projection. The memory layer does not know about backends — it targets a Knowledge Source, and the Knowledge Source owns the backend type. Adding new backends requires no changes to memory tools. - ---- - -## 3. What exists today (as of 2026-05-29) - -### Knowledge backends (live) - -Three backends exist, all implementing a common `KnowledgeBackend` ABC: - -| Backend | Type | When to use | Dependencies | -|---------|------|-------------|--------------| -| `sqlite_fts` | Keyword (BM25) | Always available | None | -| `sqlite_vec` | Vector (semantic) | Semantic similarity | pysqlite3-binary + sqlite-vec | -| `chroma` | Vector (semantic) | Separate vector store, optionally server-mode | chromadb + llama-index | - -Selected per `Knowledge Source` via `knowledge_type` field. Adding new backends (pg_vector, Qdrant, Weaviate) means implementing `KnowledgeBackend` and registering it — no memory layer changes needed. - -### Memory Record (PR #275) - -DocType with full schema: scopes, visibility, lifecycle, projection fields, quality signals (confidence, importance_score), tags, TTL, supersession. - -Scopes: Conversation / User / Role / Agent / Site / Global -Record types: Fact / Preference / Decision / Pattern / Research / Instruction - -Projection pipeline: -- Memory Record → formatted text → Knowledge Input (Text) → Knowledge Source queue -- Projection status: `Not Indexed → Queued → Projected → Error / Removed` -- "Projected" = handed to Knowledge Input pipeline. Actual indexing is async in Knowledge Input. - -Permission model: -- Desk access: System Manager + Huf Manager only -- User/agent access: through tool-level scope enforcement only -- Role/Site/Global write: Managers only -- Knowledge promotion: Managers only - -### Memory Policy (PR #275) - -Config shell — full schema, no runtime enforcement yet. - -Fields cover: capture mode, approval rules, retrieval injection mode, token budget, auto-promote thresholds, allowed record types, lifecycle TTL. - -Runtime enforcement begins in Phase 2. - -### Memory tools (PR #275) - -Five whitelisted handlers, agent-callable via native tool types: -- `save_memory_record` — scoped write with permission enforcement -- `get_memory_record` — scoped read with permission enforcement -- `search_memory_records` — multi-scope search, query filter, limit cap (max 50) -- `archive_memory_record` — sets status to Archived -- `promote_memory_to_knowledge` — manager-only, queues projection - ---- - -## 4. Terminology - -| Term | Meaning | -|------|---------| -| Conversation Data | Temporary working state for one session | -| Memory Record | Canonical scoped learned fact/preference/decision/pattern | -| Memory Policy | Config governing capture, retrieval, injection, promotion | -| Knowledge Projection | Act of converting Memory Record → Knowledge Input → indexed | -| Knowledge Source | Indexed searchable store with a pluggable backend | -| Learning Trigger | When post-run extraction fires (end of conversation, every N turns, manual) | -| Learning Agent | An Agent configured to read transcripts and extract Memory Records | -| Learning Profile | Named Memory Policy preset (conservative, conversational, research, operational) | -| Agno-direction | Pluggable reader/store/retrieval pattern (backends exist, hybrid search is Phase 5) | -| Hindsight-direction | Post-run extract/retain/reflect pattern (Phase 3) | - ---- - -## 5. Scope matrix - -| Scope | Writer | Reader | scope_key | -|-------|--------|--------|-----------| -| Conversation | Any authenticated user | Same conversation | conversation docname | -| User | That user only | That user only | frappe.session.user | -| Role | Managers only | Users with that role (visibility=Shared with Role) | role name | -| Agent | Managers (or policy-allowed agent) | Agent with matching name | agent docname | -| Site | Managers only | Everyone (visibility=Site) | frappe.local.site | -| Global | Managers only | Everyone (visibility=Global) | "global" | - ---- - -## 6. Reference architecture and influences - -### Agno (phidata) framework - -Agno separates agents into memory (short-term), knowledge (indexed content), and storage (long-term runs). Its knowledge system uses pluggable readers and vector stores. - -**What HUF adopted:** Pluggable backend ABC, clean separation of ingestion from retrieval, agent-linked knowledge sources. - -**What HUF does differently:** Frappe DocType ownership, multi-tenancy via scopes, permission governance. - -### Hindsight memory pattern - -Post-run memory consolidation with retain/recall/reflect operations. A reflection agent reads conversation transcripts and extracts durable learnings. - -**What HUF adopted:** Learning agent delegation, draft-first extraction, approval workflow, turn-based and session-end triggers. - -**What HUF defers:** Periodic reflection/consolidation across many memories (Phase 3+), contradiction detection, automatic supersession. - -### Mem0 / MemGPT - -Structured memory schemas with episodic/semantic/procedural types, importance scoring, visibility controls. - -**What HUF adopted:** Record type taxonomy, importance_score + confidence fields, visibility model. - ---- - -## 7. Implementation phases - -For full details, see [phase-plan.md](./memory/phase-plan.md). - -### Phase 0 — Alignment ✅ - -RFC, terminology, architecture decisions documented. - -### Phase 1 — Canonical Memory Record + Tools ✅ (PR #275) - -Memory Record DocType, Memory Policy (config shell), 5 tool handlers, projection pipeline, native tool wiring, manager-only Desk access. - -### Phase 2 — Policy Enforcement: Inject + Auto-promote - -Agent-linked memory policy, site-wide default policy, policy resolver. Runtime enforcement of inject_mode, token_budget, auto-promote thresholds. - -### Phase 3 — Learning: Post-run Extraction - -Memory Policy learning section (trigger, turns, learning_agent, approval). Background extraction job. Learning agent delegation. Draft-first safety. - -### Phase 4 — Learning Profiles + Learning Agent Formalization - -Named presets (Minimal, Conversational, Research, Operational). Learning Agent role type. Sensible defaults for agents without explicit policy. - -### Phase 5 — Retrieval Upgrades: Hybrid Search + Metadata Filters - -Hybrid FTS + vector scoring per Knowledge Source. Metadata filters (scope, tag, record_type). Chunk cleanup on Knowledge Input deletion. Abstract `supports_metadata_filters()` and `supports_hybrid_search()` on backends. - ---- - -## 8. Data quality and safety rules - -1. Raw conversation fragments should not automatically become long-term knowledge. -2. Derived records carry provenance (source_type, run, conversation). -3. Low-confidence records should remain Draft unless explicitly activated. -4. User-scoped memory must never appear in Role/Site/Global retrieval. -5. Role/Site/Global memory requires Manager-level write access. -6. Promotion to Knowledge is reversible (remove_knowledge_projection). -7. Indexes are rebuildable from canonical Memory Records. -8. Extracted records are Draft by default when approval_required = True. - ---- - -## 9. Non-goals (permanent) - -- Do not replace existing Knowledge Sources or Agent Knowledge semantics. -- Do not auto-index all conversation data. -- Do not make Hindsight a hard external dependency. -- Do not make vector DB dependencies mandatory (sqlite_fts always works without extras). -- Do not bypass Frappe permissions. -- Do not expose private user memory across role/site/global boundaries. - ---- - -## 10. Open questions - -1. Should memory injection be visible to users in the chat UI? (transparency) -2. Should users be able to view and delete their own User-scoped memory? -3. How should contradictory memory records be handled in Phase 3+? -4. Should Data Tables become eligible memory sources in a future phase? -5. What telemetry captures memory injection quality and extraction cost? -6. Should a formal Memory Record approval workflow (Frappe Workflow) be added in Phase 2? diff --git a/docs/memory/phase-plan.md b/docs/memory/phase-plan.md deleted file mode 100644 index b795fc14..00000000 --- a/docs/memory/phase-plan.md +++ /dev/null @@ -1,313 +0,0 @@ -# HUF Memory Architecture: Implementation Phase Plan - -> **Status:** Active — Phase 1 in progress (PR #275) -> **Last updated:** 2026-05-29 -> **Related:** [zero-to-hero.md](./zero-to-hero.md) | [RFC](../SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md) - ---- - -## Overview - -This document defines the phased delivery plan for HUF's memory and learning architecture. Each phase is independently mergeable and leaves the system in a stable, useful state. - -``` -Phase 0 → Architecture alignment (done) -Phase 1 → Canonical Memory Record + tools (PR #275, in progress) -Phase 2 → Policy enforcement: inject + auto-promote -Phase 3 → Learning: post-run extraction -Phase 4 → Learning profiles + learning agent formalization -Phase 5 → Retrieval upgrades: hybrid search, metadata filters -``` - ---- - -## Phase 0 — Architecture Alignment ✅ Done - -**Branch:** `docs/scoped-memory-knowledge-bridge` | **PR:** #274 - -**Deliverables:** -- RFC defining the three-layer model: Conversation Data → Memory Record → Knowledge Source -- Terminology alignment: Memory Record vs Knowledge Input vs Knowledge Source vs Learning -- Phase roadmap (superseded by this document) - -**Status:** Merged to `docs/` branch. RFC lives at `docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md`. - ---- - -## Phase 1 — Canonical Memory Record + Tools 🔄 In Progress - -**Branch:** `feature/scoped-memory-core` | **PR:** #275 - -### Goal - -Ship a safe, internally consistent MVP: a governed Memory Record store with tool access for agents, and a working pipeline to promote records to Knowledge. - -### What is included - -**DocTypes:** -- `Memory Record` — full schema: scopes, visibility, lifecycle, projection fields, quality signals -- `Memory Policy` — config shell for future enforcement. Schema complete, no runtime enforcement yet. - -**Backend tools (whitelisted, agent-callable):** -- `save_memory_record` — scoped write with permission enforcement -- `get_memory_record` — scoped read with permission enforcement -- `search_memory_records` — multi-scope search, query filtering, limit cap -- `archive_memory_record` — sets status to Archived, checks both read + write permission -- `promote_memory_to_knowledge` — manager-only, queues projection to Knowledge Input - -**Knowledge projection pipeline:** -- Memory Record → formatted text → Knowledge Input (input_type=Text) → Knowledge Source queue -- Projection status: `Not Indexed → Queued → Projected → Error / Removed` -- `Projected` means Memory Record has been handed to Knowledge Input pipeline -- Actual indexing status is owned by Knowledge Input (Pending → Processing → Indexed → Error) -- Re-projection on `summary_text` or `data_json` change updates existing Knowledge Input rather than creating duplicates - -**Permission model:** -- Desk access to Memory Record: System Manager and Huf Manager only -- User/agent access: through tool-level scope/visibility filtering only -- Normal users: can write Conversation and their own User memory -- Managers: can write any scope, promote to knowledge - -**Native tool wiring:** -- 5 new types in `agent_tool_function.json`: Save Memory Record, Search Memory Records, Get Memory Record, Archive Memory Record, Promote Memory to Knowledge -- Each type maps to the corresponding handler in `huf/ai/memory_tools.py` - -### What is explicitly NOT included - -- Memory Policy runtime enforcement (config shell only) -- Automatic memory capture from runs -- Frontend memory tab or UI (Desk only, manager-visible) -- New vector DB logic -- Full chunk cleanup on Knowledge Input deletion (to be addressed in Phase 5) -- Hindsight-style retain/recall/reflect - -### Definition of done - -- [ ] `python -m py_compile` passes on all three new Python files -- [ ] `bench migrate` applies cleanly -- [ ] Memory Record can be created, activated, promoted to Knowledge -- [ ] Projection status shows `Projected` (not `Indexed`) after queuing -- [ ] Normal user cannot create Role/Site/Global memory via tools -- [ ] Manager can promote memory to an existing Knowledge Source -- [ ] Agent Tool Function can use Save Memory Record and Search Memory Records types - ---- - -## Phase 2 — Policy Enforcement: Inject + Auto-promote - -**Depends on:** Phase 1 merged - -### Goal - -Make Memory Policy do something at runtime. Focus on the two highest-value enforcement paths: injecting memory into agent context before a run, and auto-promoting records that meet quality thresholds. - -### What is included - -**Agent-level policy linking:** -- Add `memory_policy` Link field to Agent DocType -- Add `default_memory_policy` Link field to Agent Settings (singleton) for site-wide fallback -- Policy resolver: `resolve_memory_policy(agent_name)` → Agent Policy → Site Default → None - -**New module: `huf/ai/memory_policy_resolver.py`** -- `resolve_memory_policy(agent_name)` — returns the effective MemoryPolicy doc or None -- `get_injectable_memory(agent_name, conversation_id, policy)` — returns list of Memory Records within token budget -- `build_memory_context_block(records, policy)` — formats records for system prompt injection - -**Hook into agent_integration.py:** -- Before run: if `inject_mode != "None"` → prepend memory context block to system prompt -- After run: if `auto_promote_to_knowledge` → check records meeting min_confidence + min_importance → queue projection - -**Injection modes:** -- `None` — no injection (default) -- `Append to System Prompt` — memory records added to system prompt as a structured block -- `Tool Available` — inject nothing, but ensure `search_memory_records` tool is available to the agent - -**Token budget enforcement:** -- Records sorted by `importance_score desc, modified desc` -- Trimmed to fit within `token_budget` (estimated at 4 chars/token) -- If budget exceeded, lower-importance records are dropped silently - -**Auto-promote rule:** -- Background job checks new/updated Memory Records for the policy -- If `promote_to_knowledge = False` and record meets `promotion_min_confidence` + `promotion_min_importance`, set `promote_to_knowledge = 1` and queue projection - -### What is explicitly NOT included - -- Capture-side enforcement (auto-extraction from runs is Phase 3) -- `allow_role_scope_write` enforcement (manual manager override only for now) - ---- - -## Phase 3 — Learning: Post-run Extraction - -**Depends on:** Phase 2 merged - -### Goal - -Let Memory Policy control when and how the system extracts Memory Records from completed agent runs. Optionally delegate extraction to a dedicated learning agent. - -### What is included - -**Memory Policy — new Learning section fields:** -- `learning_enabled` (Check) -- `learning_trigger` (Select: Manual | End of Conversation | Every N Turns) -- `turns_per_extraction` (Int — used when trigger = Every N Turns) -- `learning_agent` (Link → Agent — optional, delegates extraction) -- `extracted_record_default_status` (Select: Draft | Active) -- `extraction_model` (Data — optional model override for built-in extraction) - -**New module: `huf/ai/memory_extractor.py`** -- `extract_memories_from_run(agent_run_id, memory_policy_name)` — main entry point -- Assembles conversation transcript from Agent Messages -- If `learning_agent` set: routes to that agent via `run_agent_sync()`, parses structured output -- If not: calls built-in extraction prompt against base model -- Saves extracted records with source_type = "Agent Run", run = agent_run_id - -**Trigger hooks:** -- End of Conversation: hook on Agent Conversation `on_update` when status changes to Closed/Complete -- Every N Turns: hook on Agent Message `after_insert`, count turns, fire when threshold reached -- Manual: whitelist endpoint `trigger_memory_extraction(agent_run_id)` for explicit calls - -**Extraction output format:** -- Learning agent / extraction prompt returns JSON list of memory record drafts -- Each includes: title, summary_text, record_type, confidence, importance_score, tags -- Scope defaults to Conversation for raw extractions; manager can promote later - -**Draft-first safety:** -- Default status controlled by `extracted_record_default_status` -- If `approval_required = True`, always Draft regardless of setting -- Desk review queue for Draft records: filter Memory Records by status=Draft, source_type=Agent Run - -### Built-in extraction prompt - -The default extraction prompt (when no learning_agent is set) will: -1. Receive the conversation transcript -2. Identify facts, preferences, decisions, patterns -3. Output structured JSON matching Memory Record fields -4. Not invent information not present in the transcript - ---- - -## Phase 4 — Learning Profiles + Learning Agent Formalization - -**Depends on:** Phase 3 merged - -### Goal - -Provide named configuration presets (Learning Profiles) so agents can adopt a sensible memory behavior without hand-crafting a Memory Policy from scratch. Formalize the Learning Agent pattern. - -### What is included - -**Learning Profiles (Memory Policy presets):** -Built-in presets seeded at install time: - -| Profile | capture_mode | inject_mode | approval_required | learning_trigger | -|---------|-------------|-------------|-------------------|-----------------| -| Minimal | Manual | None | Yes | Manual | -| Conversational | Auto | Append to System | No | End of Conversation | -| Research | Both | Tool Available | Yes | End of Conversation | -| Operational | Auto | Append to System | No | Every N Turns (5) | - -- Profiles are regular Memory Policy docs with `is_preset = True` -- Agents can link to a preset directly or clone it for customization -- Site admins can define additional custom presets - -**Learning Agent pattern:** -- `Agent` DocType gains `agent_role` field (Select: General | Learning | Orchestrator | ...) -- A Learning Agent has `agent_role = Learning` and a specialized system prompt -- Memory Policy's `learning_agent` field filtered to agents with `agent_role = Learning` -- Preset learning agents provided for common extraction styles: conservative, liberal, preferences-focused - -**Agent Settings:** -- `default_learning_profile` — fallback profile for agents without an explicit memory_policy link - ---- - -## Phase 5 — Retrieval Upgrades: Hybrid Search + Metadata Filters - -**Depends on:** Phase 2 merged (earlier phases are independent of this) - -### Goal - -Improve retrieval quality for memory-projected knowledge and existing Knowledge Sources. Enable hybrid FTS + vector scoring, metadata filtering by scope/tag/record_type, and prepare the backend for additional vector stores. - -### What is included - -**Hybrid search (per Knowledge Source):** -- Knowledge Sources with `knowledge_type = sqlite_fts` or `sqlite_vec` can opt-in to hybrid mode -- Hybrid mode runs both FTS and vector search, combines scores (reciprocal rank fusion) -- New `search_mode` field on Knowledge Source: FTS | Vector | Hybrid -- Hybrid requires embedding to be configured - -**Metadata filters in retrieval:** -- Knowledge Input gains optional `metadata_json` field for tags, scope_type, record_type -- During projection, Memory Record tags + scope_type + record_type written to Knowledge Input metadata -- `knowledge_search()` accepts `metadata_filters` dict to narrow results -- Example: `{"record_type": "Preference", "tags": "hospitality"}` → only matching chunks returned - -**Memory-specific search helper:** -- `search_memory_knowledge(query, agent_name, filters)` — searches Knowledge Sources containing projected memory -- Returns results attributed to source Memory Record (via knowledge_input → memory record backlink) - -**New backend scaffolding:** -- Abstract `supports_metadata_filters()` method on `KnowledgeBackend` -- Abstract `supports_hybrid_search()` method -- Concrete backends implement or return False -- Future backends (pg_vector, Qdrant, Weaviate) can implement both - -**Chunk cleanup on Knowledge Input deletion:** -- `KnowledgeInput.on_trash()` calls `backend.delete_chunks(input_id)` before deletion -- Ensures removing a projected Memory Record actually removes indexed content - ---- - -## Cross-cutting concerns - -### Security model (all phases) - -- Memory Record Desk access: Managers only -- Tool-level access: scoped per user/role/agent context -- Wider scopes (Role, Site, Global): Managers only -- Knowledge promotion: Managers only -- Extracted draft records: visible to managers for review before activation -- User-scoped memory: never leaks to role/site/global search - -### Testing strategy - -**Phase 1:** -- Controller validation tests (scope key, status, projection settings) -- Tool permission tests (who can write which scope) -- Projection lifecycle tests (status = Projected, re-projection updates existing KI) - -**Phase 2:** -- Policy resolver tests (agent policy vs site default vs None) -- Injection formatting tests (token budget trimming) -- Auto-promote threshold tests - -**Phase 3:** -- Extraction trigger tests (end of conversation, every N turns) -- Learning agent delegation tests (mock agent output → memory records) -- Draft/active status based on approval_required - -**Phase 5:** -- Hybrid search scoring tests -- Metadata filter tests per backend -- Chunk cleanup verification - -### Backward compatibility - -- Phase 1 adds new DocTypes — no changes to existing DocTypes -- Phase 2 adds optional fields to Agent and Agent Settings — no breaking changes -- Phase 3 adds optional fields to Memory Policy — no breaking changes -- Phase 5 adds optional fields to Knowledge Source and Knowledge Input — no breaking changes -- At no phase is existing Knowledge Source or Agent Tool Function behavior changed without opt-in - ---- - -## Open questions - -1. Should memory injection be visible to the user in the chat UI? (Attribution / transparency) -2. Should users be able to view and delete their own User-scoped memory via the chat UI? -3. How should contradictory memory records be handled in Phase 3+ (flag only, or attempt resolution)? -4. Should Data Tables be eligible as memory sources in a future phase? -5. What telemetry should be captured for memory injection quality and extraction cost? diff --git a/docs/memory/zero-to-hero.md b/docs/memory/zero-to-hero.md deleted file mode 100644 index 1fd88140..00000000 --- a/docs/memory/zero-to-hero.md +++ /dev/null @@ -1,324 +0,0 @@ -# HUF Memory Architecture: Zero to Hero - -> **Who this is for:** Anyone new to the memory/knowledge area of HUF — engineers, contributors, or agents reading this cold. This document captures the full intellectual journey: where the ideas came from, what was tried, what was decided, and why. Read this before touching any memory-related code. - ---- - -## 1. The problem we are solving - -HUF started as a conversational AI platform. Agents talk to users, run tools, and produce results. But every conversation was a blank slate. Agents had no memory of what they'd learned, no way to build up preferences or patterns over time, and no way to carry useful facts from one session to the next. - -This became a real limitation: - -- A user tells an agent their preferences in one conversation. The agent has forgotten them in the next. -- An agent learns a reliable routing pattern after many runs. That learning disappears. -- Research done in one session cannot be made available as searchable knowledge for other agents. -- There is no way to say "this fact, learned from a conversation, should be considered authoritative at the site level." - -The gap was: **HUF had Knowledge Sources (static documents, PDFs, URLs) and conversation-local working data, but nothing in between** — no layer for learned, scoped, evolving data that could optionally become searchable. - ---- - -## 2. The reference systems that shaped the design - -Before arriving at the current architecture, several external systems were studied carefully. Understanding these is essential for understanding why HUF's design looks the way it does. - -### 2.1 Agno (formerly phidata) - -Agno is an open-source Python framework for building multi-modal AI agents. It has a clean separation between: - -- **Agent memory**: short-term per-session state -- **Agent knowledge**: structured, indexed, searchable content (PDFs, URLs, tables, text) -- **Agent storage**: long-term persistence of runs and sessions - -Agno's knowledge system uses a `Knowledge` class with pluggable readers (PDFReader, URLReader, etc.) and vector stores (pgvector, Qdrant, Pinecone, LanceDB, etc.). It separates the *reader* (how you get text from a source) from the *store* (how you index and retrieve it). - -**What HUF borrowed from Agno:** -- The concept of pluggable knowledge backends (sqlite_fts, sqlite_vec, chroma — all implement a common `KnowledgeBackend` ABC) -- The idea that agents should be able to search knowledge before responding -- The pattern of separating ingestion from retrieval - -**What HUF did differently:** -- HUF wraps this in Frappe DocTypes (Knowledge Source, Knowledge Input) so it integrates with the rest of the platform's permissions, workflows, and UI -- HUF has a stronger multi-tenancy and scoping requirement (User, Role, Agent, Site, Global) - -### 2.2 Hindsight (memory consolidation pattern) - -Hindsight is a research-inspired design pattern for long-term agent memory. The core idea is three operations: - -- **retain**: extract and save something worth remembering from a conversation -- **recall**: retrieve relevant memory when starting a new conversation -- **reflect**: periodically consolidate, deduplicate, and upgrade memory quality - -Hindsight-style systems typically run as a background process after each conversation. A second LLM call (the "reflection agent") reads the transcript and decides what to save. - -**What HUF borrowed from Hindsight:** -- The idea of a dedicated "learning agent" that reads transcripts and extracts memory -- The concept of a learning trigger (end of conversation, every N turns) -- The draft → review → active lifecycle for extracted memories - -**What HUF is NOT doing (yet):** -- Hindsight's reflect step (periodic consolidation across many memories) is not implemented -- Automatic contradiction detection is not implemented -- Memory decay and supersession is manual, not automatic - -### 2.3 Mem0 / MemGPT patterns - -These systems maintain a dedicated memory layer that agents read from and write to during a conversation, with a structured schema for different memory types (episodic, semantic, procedural). - -**What HUF borrowed:** -- The scoped record type model (Fact, Preference, Decision, Pattern, Research, Instruction) -- The importance score + confidence fields for quality filtering -- The visibility model (Private, Shared with Role, Site, Global) - ---- - -## 3. How HUF's existing knowledge system works - -Before memory makes sense, you need to understand knowledge. Here is the actual pipeline: - -``` -Knowledge Source (DocType) -├── knowledge_type: sqlite_fts | sqlite_vec | chroma -├── embedding_model (for vector backends) -└── Knowledge Inputs [] - ├── input_type: Text | File | URL - ├── status: Pending | Processing | Indexed | Error - └── text / file / url content - -Indexing pipeline (huf/ai/knowledge/indexer.py): - Knowledge Input → extract text → chunk → embed (if vector) → store in backend - -Retrieval pipeline (huf/ai/knowledge/retriever.py): - query → embed query → search backend → return ChunkResult[] -``` - -**Three backends exist today, all implementing `KnowledgeBackend` ABC:** - -| Backend | Type | When to use | Dependencies | -|---------|------|-------------|--------------| -| `sqlite_fts` | Keyword (BM25) | Always available, no GPU needed | None | -| `sqlite_vec` | Vector (semantic) | When you need semantic similarity | pysqlite3-binary + sqlite-vec | -| `chroma` | Vector (semantic) | When you want a separate vector store, optionally server-mode | chromadb + llama-index | - -**Adding a new backend** (e.g., pg_vector) means: -1. Implement `KnowledgeBackend` ABC in `huf/ai/knowledge/backends/` -2. Register it in `get_backend()` in `__init__.py` -3. Add the option to Knowledge Source's `knowledge_type` Select field - -The memory layer **never needs to change** when backends are added or removed. - ---- - -## 4. The memory architecture - -### 4.1 The three-layer model - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Conversation Data (temporary, per-session) │ -│ → selected items, form values, current state, agent working memory │ -│ → lives in Agent Conversation / run context only │ -└────────────────────────────┬────────────────────────────────────────┘ - │ manual save or policy-triggered extract - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Memory Record (canonical, scoped, governed) │ -│ → Fact, Preference, Decision, Pattern, Research, Instruction │ -│ → scoped: Conversation / User / Role / Agent / Site / Global │ -│ → governed by Memory Policy │ -└────────────────────────────┬────────────────────────────────────────┘ - │ promote_to_knowledge (explicit or auto) - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Knowledge Source (indexed, searchable) │ -│ → sqlite_fts | sqlite_vec | chroma | future: pg_vector... │ -│ → used by agents via mandatory/optional knowledge linking │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -**Key principle:** Memory Record does not know about backends. It targets a Knowledge Source. The Knowledge Source owns the backend type. This means new backends require zero changes to memory tools or Memory Policy. - -### 4.2 Memory Record scopes - -| Scope | Who can write | Who can read | scope_key value | -|-------|--------------|--------------|-----------------| -| Conversation | Any user (in that conversation) | Same conversation only | conversation docname | -| User | That user only | That user only | frappe.session.user | -| Role | Managers only | Users with that role + visibility="Shared with Role" | role name | -| Agent | Managers only (or agent if allowed by policy) | Agent with matching name | agent docname | -| Site | Managers only | Everyone if visibility="Site" | frappe.local.site | -| Global | Managers only | Everyone if visibility="Global" | "global" | - -### 4.3 Memory Policy - -Memory Policy is the governance and behavior config layer. It sits between Memory Records and agent runtime. - -**What it configures (fields exist, enforcement is phased):** - -``` -Capture: - capture_mode: Manual | Auto (on_run_end) | Both - approval_required: bool - default_status: Draft | Active - allowed_record_types: [Fact, Preference, ...] - -Retrieval: - inject_mode: None | Append to System Prompt | Tool Available - max_records: int - token_budget: int - -Write controls: - allow_agent_write: bool - allow_user_scope_write: bool - allow_role_scope_write: bool (manager override) - -Projection: - auto_promote_to_knowledge: bool - knowledge_source: Link → Knowledge Source - promotion_min_confidence: float - promotion_min_importance: float - -Lifecycle: - ttl_days: int -``` - -Policy resolution at runtime: **Agent Policy → Site Default → built-in safe defaults.** - ---- - -## 5. The learning system - -### 5.1 What "learning" means here - -An agent "learns" when something worth remembering is extracted from a run or conversation and saved as a Memory Record for future use. This is different from fine-tuning the model. It is structured, auditable, and reversible. - -### 5.2 How extraction works (Phase 3) - -When a learning trigger fires: -1. The agent run transcript is assembled -2. If `learning_agent` is set on the Memory Policy, that agent is called with the transcript -3. If not, a built-in extraction prompt runs against the same base model -4. Extracted facts/preferences/decisions are saved as Memory Records -5. Default status is `Draft` (if `approval_required`) or `Active` (if not) - -### 5.3 Learning triggers - -| Trigger | When it fires | -|---------|--------------| -| Manual | Only when explicitly called | -| End of Conversation | When conversation is closed / marked complete | -| Every N Turns | After every N agent turns in the conversation | - -### 5.4 Learning agent pattern - -A "learning agent" is just a regular HUF Agent with a specialized system prompt. It receives a conversation transcript and returns structured memory records. This means: -- You can use any model for extraction (not necessarily the same as the active agent) -- You can version and iterate the extraction prompt without touching the main agent -- You can inspect what the learning agent produces before it becomes Active - ---- - -## 6. What exists today vs what is planned - -### Today (after PR #275 is merged) - -| Capability | Status | -|-----------|--------| -| Memory Record DocType (full schema) | ✅ Done | -| Memory Policy DocType (schema + validation) | ✅ Done (config shell, no runtime enforcement) | -| 5 memory tool handlers | ✅ Done | -| Scoped permission enforcement in tools | ✅ Done | -| Memory → Knowledge Input projection | ✅ Done | -| Projection status tracking | ✅ Done (`Projected`, not `Indexed`) | -| Manager-only Desk access | ✅ Done | -| Native tool wiring in Agent Tool Function | ✅ Done | -| Memory Policy enforcement at runtime | ❌ Phase 2 | -| Agent-linked memory policy | ❌ Phase 2 | -| Auto-inject memory into agent context | ❌ Phase 2 | -| Post-run memory extraction | ❌ Phase 3 | -| Learning agent delegation | ❌ Phase 3 | -| Learning profiles (presets) | ❌ Phase 4 | -| Hybrid FTS + vector search for memory | ❌ Phase 5 | - -### Not in scope (ever, by design) - -- Automatic promotion of all conversation data to memory (too noisy) -- Fine-tuning or model weight updates -- Replacing Frappe permissions with custom auth -- Memory leaking across user/role/site boundaries - ---- - -## 7. Key files and where to look - -| What | Where | -|------|-------| -| Memory tool handlers (save/get/search/archive/promote) | `huf/ai/memory_tools.py` | -| Memory Record controller (validation, projection queue) | `huf/huf/doctype/memory_record/memory_record.py` | -| Memory Record schema | `huf/huf/doctype/memory_record/memory_record.json` | -| Memory Policy controller | `huf/huf/doctype/memory_policy/memory_policy.py` | -| Memory Policy schema | `huf/huf/doctype/memory_policy/memory_policy.json` | -| Knowledge backend abstraction | `huf/ai/knowledge/backends/__init__.py` | -| FTS backend | `huf/ai/knowledge/backends/sqlite_fts.py` | -| Vector backend | `huf/ai/knowledge/backends/sqlite_vec_backend.py` | -| ChromaDB backend | `huf/ai/knowledge/backends/chroma_backend.py` | -| Indexing pipeline | `huf/ai/knowledge/indexer.py` | -| Retrieval pipeline | `huf/ai/knowledge/retriever.py` | -| Knowledge Source controller | `huf/huf/doctype/knowledge_source/knowledge_source.py` | -| Phase plan | `docs/memory/phase-plan.md` | -| RFC (architecture decisions) | `docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md` | - ---- - -## 8. How to contribute - -**Adding a new knowledge backend:** -1. Implement `KnowledgeBackend` in `huf/ai/knowledge/backends/` -2. Register in `get_backend()` in `__init__.py` -3. Add option to Knowledge Source `knowledge_type` field -4. Add validation in `knowledge_source.py` if dependencies need checking - -**Adding a new memory scope or record type:** -- Scope types: add to `scope_type` Select field in `memory_record.json`, update `can_read()` and `can_write()` in `memory_tools.py`, update scope resolver in `resolved_key()` -- Record types: add to `record_type` Select field in `memory_record.json` (no code changes needed) - -**Implementing a new Memory Policy enforcement (Phase 2+):** -- The policy resolver logic will live in `huf/ai/memory_policy_resolver.py` (to be created) -- It should read the agent's linked policy or fall back to site default from Agent Settings -- Hook into `agent_integration.py` before and after agent runs - -**Writing a learning agent:** -- Create a regular Agent with a specialized system prompt for memory extraction -- The system prompt should instruct the agent to output structured memory records -- Link it as `learning_agent` in a Memory Policy - ---- - -## 9. Design decisions and their reasons - -| Decision | Why | -|---------|-----| -| Memory Record doesn't know about backends | Adding pg_vector shouldn't require touching memory tools | -| Memory Policy is config-shell-first | The schema needs to stabilize before enforcement. Wrong enforcement is worse than no enforcement. | -| Manager-only Desk access to Memory Records | Desk DocPerm can't enforce per-scope visibility rules. Tool-level access is the correct control path for users. | -| Projection status = "Projected" not "Indexed" | "Indexed" implies the knowledge pipeline has completed. It hasn't — Knowledge Input processing is async. "Projected" means we've handed it off. | -| Learning agent is a regular Agent, not special-cased | Reuses the entire Agent infrastructure. Can be versioned, tested, and swapped independently. | -| Draft default for extracted memory | Automatic extraction without human review is a data quality risk. Draft-first is safer. | - ---- - -## 10. Glossary - -| Term | Meaning | -|------|---------| -| **Memory Record** | A canonical scoped fact, preference, decision, pattern, or research note. The source of truth for what an agent has learned. | -| **Memory Policy** | Config governing capture, retrieval, injection, and promotion rules for memory records. Linked to an Agent or set site-wide. | -| **Knowledge Source** | A HUF DocType representing an indexed, searchable knowledge store. Has a pluggable backend (FTS, vector, chroma). | -| **Knowledge Input** | A single item (text, file, URL) that gets indexed into a Knowledge Source. | -| **Knowledge Projection** | The act of converting a Memory Record into a Knowledge Input so it becomes searchable. Status = Projected means it has been handed to the Knowledge Input pipeline. | -| **Learning Agent** | A regular HUF Agent configured specifically to read transcripts and extract Memory Records. | -| **Scope** | The boundary within which a Memory Record is valid and visible. (Conversation / User / Role / Agent / Site / Global) | -| **Visibility** | Fine-grained access control within a scope. (Private / Shared with Role / Shared with Agent / Site / Global) | -| **Agno-direction** | Pluggable reader/store architecture for knowledge, inspired by the Agno (phidata) framework. | -| **Hindsight-direction** | Post-run memory extraction pattern with retain/recall/reflect semantics. | diff --git a/docs/superpowers/specs/2026-05-29-memory-architecture-design.md b/docs/superpowers/specs/2026-05-29-memory-architecture-design.md deleted file mode 100644 index 72f4c99d..00000000 --- a/docs/superpowers/specs/2026-05-29-memory-architecture-design.md +++ /dev/null @@ -1,31 +0,0 @@ -# Memory Architecture Design - -**Date:** 2026-05-29 -**Status:** Approved -**Outcome:** Phase plan and RFC updated. Phase 1 (PR #275) hardening next. - ---- - -## Decisions made - -### Backend coupling -Memory Record → Knowledge Source → Backend (loose). Memory tools never reference backend types. New backends (pg_vector, Qdrant) implemented as `KnowledgeBackend` subclasses. Zero memory layer changes needed. - -### Memory Policy activation -Phase 2: Agent-linked policy + site-wide default. Resolver chain: Agent Policy → Site Default → built-in safe defaults. Enforcement of inject_mode and token_budget first. - -### Learning model -Hybrid: tool-driven saves now (Phase 1), policy-triggered extraction in Phase 3. Granular triggers: End of Conversation, Every N Turns, Manual. Optional delegation to a Learning Agent (a regular HUF Agent with specialized prompt). Draft-first by default. - -### Output -- Updated `docs/SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md` — corrected, reflects live backends + phases -- New `docs/memory/zero-to-hero.md` — full intellectual context doc for new contributors -- New `docs/memory/phase-plan.md` — per-phase delivery plan with definition of done - ---- - -## Documents produced - -- [RFC](../../SCOPED_MEMORY_KNOWLEDGE_BRIDGE_RFC.md) -- [Zero to Hero](../../memory/zero-to-hero.md) -- [Phase Plan](../../memory/phase-plan.md) From 5b68e8535ac538d025b56978da329220494d1131 Mon Sep 17 00:00:00 2001 From: esafwan Date: Fri, 29 May 2026 02:43:50 +0400 Subject: [PATCH 05/12] refactor: consolidate 89 individual tools into 13 action-based tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces one-tool-per-operation pattern with an action-dispatcher pattern. Each tool file exposes a single handle_action(**kwargs) that routes via an 'action' kwarg. Internal logic is unchanged — only renamed to _handle_*. Tool count: 89 → 13 slack (6 actions: send_message, reply_thread, list_channels, ...) discord (4 actions: send_message, get_messages, list_channels, ...) telegram (1 action: send_message) github (6 actions: list_repos, get_repo, create_issue, ...) get_recipient (utility, unchanged) frappe_crm (11 actions: leads + deals + notes + tasks + contacts) helpdesk (8 actions: tickets + comments + agents + teams) raven (6 actions: messages + channels + members) erpnext (14 actions: invoices + payments + customers + ledger + ...) erpnext_crm (7 actions: leads + opportunities) erpnext_inventory (12 actions: items + BOM + stock + warehouses + ...) erpnext_run_report — run any ERPNext report by name + filters dict erpnext_list_reports — discover ~90 reports across 9 modules erpnext_reports.py replaced with a REPORT_CATALOGUE covering 90+ reports across Accounts, Selling, Buying, Stock, Manufacturing, CRM, Helpdesk, Projects and HR — all accessible via two generic tools instead of 14+ individual ones. --- huf/ai/tools/_registry.py | 1454 +++++------------------------ huf/ai/tools/crm.py | 61 +- huf/ai/tools/discord.py | 23 +- huf/ai/tools/erpnext.py | 53 +- huf/ai/tools/erpnext_crm.py | 32 +- huf/ai/tools/erpnext_inventory.py | 47 +- huf/ai/tools/erpnext_reports.py | 488 ++-------- huf/ai/tools/github.py | 29 +- huf/ai/tools/helpdesk.py | 35 +- huf/ai/tools/raven.py | 29 +- huf/ai/tools/slack.py | 29 +- huf/ai/tools/telegram.py | 14 +- 12 files changed, 633 insertions(+), 1661 deletions(-) diff --git a/huf/ai/tools/_registry.py b/huf/ai/tools/_registry.py index 5e0011a3..7d57c1b4 100644 --- a/huf/ai/tools/_registry.py +++ b/huf/ai/tools/_registry.py @@ -11,1210 +11,270 @@ def _p(name, type="string", required=False, description=""): - return { - "label": name.replace("_", " ").title(), - "fieldname": name, - "type": type, - "required": int(required), - "description": description, - } + return { + "label": name.replace("_", " ").title(), + "fieldname": name, + "type": type, + "required": int(required), + "description": description, + } -# --------------------------------------------------------------------------- -# Communication Tools -# --------------------------------------------------------------------------- - -SLACK_TOOLS = [ - { - "tool_name": "slack_send_message", - "description": "Send a message to a Slack channel. Requires SLACK_TOKEN env var.", - "function_path": "huf.ai.tools.slack.handle_send_message", - "category": "Communication Tools", - "parameters": [ - _p("channel", required=True, description="Channel ID or name to send the message to"), - _p("text", required=True, description="Message text (supports Slack mrkdwn formatting)"), - ], - }, - { - "tool_name": "slack_send_thread_reply", - "description": "Reply to a message thread in a Slack channel. Requires SLACK_TOKEN env var.", - "function_path": "huf.ai.tools.slack.handle_send_message_thread", - "category": "Communication Tools", - "parameters": [ - _p("channel", required=True, description="Channel ID or name"), - _p("text", required=True, description="Reply text"), - _p("thread_ts", required=True, description="Timestamp of the parent message"), - ], - }, - { - "tool_name": "slack_list_channels", - "description": "List all channels in the Slack workspace. Requires SLACK_TOKEN env var.", - "function_path": "huf.ai.tools.slack.handle_list_channels", - "category": "Communication Tools", - "parameters": [], - }, - { - "tool_name": "slack_get_channel_history", - "description": "Get message history of a Slack channel. Requires SLACK_TOKEN env var.", - "function_path": "huf.ai.tools.slack.handle_get_channel_history", - "category": "Communication Tools", - "parameters": [ - _p("channel", required=True, description="Channel ID to fetch history from"), - _p("limit", type="integer", description="Max messages to fetch (default 100)"), - ], - }, - { - "tool_name": "slack_search_messages", - "description": "Search messages across the Slack workspace. Supports modifiers like from:@user, in:#channel. Requires SLACK_TOKEN env var.", - "function_path": "huf.ai.tools.slack.handle_search_messages", - "category": "Communication Tools", - "parameters": [ - _p("query", required=True, description="Search query"), - _p("limit", type="integer", description="Max results (default 20, max 100)"), - ], - }, - { - "tool_name": "slack_list_users", - "description": "List all users in the Slack workspace. Requires SLACK_TOKEN env var.", - "function_path": "huf.ai.tools.slack.handle_list_users", - "category": "Communication Tools", - "parameters": [ - _p("limit", type="integer", description="Max users to fetch (default 100)"), - ], - }, -] - -DISCORD_TOOLS = [ - { - "tool_name": "discord_send_message", - "description": "Send a message to a Discord channel. Requires DISCORD_BOT_TOKEN env var.", - "function_path": "huf.ai.tools.discord.handle_send_message", - "category": "Communication Tools", - "parameters": [ - _p("channel_id", required=True, description="Discord channel ID"), - _p("message", required=True, description="Message text to send"), - ], - }, - { - "tool_name": "discord_get_messages", - "description": "Get message history of a Discord channel. Requires DISCORD_BOT_TOKEN env var.", - "function_path": "huf.ai.tools.discord.handle_get_channel_messages", - "category": "Communication Tools", - "parameters": [ - _p("channel_id", required=True, description="Discord channel ID"), - _p("limit", type="integer", description="Max messages (default 50)"), - ], - }, - { - "tool_name": "discord_list_channels", - "description": "List all channels in a Discord server. Requires DISCORD_BOT_TOKEN env var.", - "function_path": "huf.ai.tools.discord.handle_list_channels", - "category": "Communication Tools", - "parameters": [ - _p("guild_id", required=True, description="Discord server (guild) ID"), - ], - }, - { - "tool_name": "discord_delete_message", - "description": "Delete a message from a Discord channel. Requires DISCORD_BOT_TOKEN env var.", - "function_path": "huf.ai.tools.discord.handle_delete_message", - "category": "Communication Tools", - "parameters": [ - _p("channel_id", required=True, description="Discord channel ID"), - _p("message_id", required=True, description="Message ID to delete"), - ], - }, -] - -TELEGRAM_TOOLS = [ - { - "tool_name": "telegram_send_message", - "description": "Send a message via Telegram bot. Requires TELEGRAM_TOKEN env var.", - "function_path": "huf.ai.tools.telegram.handle_send_message", - "category": "Communication Tools", - "parameters": [ - _p("chat_id", required=True, description="Telegram chat ID to send to"), - _p("message", required=True, description="Message text"), - ], - }, -] +def _action(choices): + return _p("action", required=True, description=f"Action to perform. One of: {choices}") -# --------------------------------------------------------------------------- - -GITHUB_TOOLS = [ - { - "tool_name": "github_list_repos", - "description": "List GitHub repositories for the authenticated user. Requires GITHUB_ACCESS_TOKEN env var.", - "function_path": "huf.ai.tools.github.handle_list_repos", - "category": "Developer Tools", - "parameters": [], - }, - { - "tool_name": "github_get_repo", - "description": "Get details of a GitHub repository. Requires GITHUB_ACCESS_TOKEN env var.", - "function_path": "huf.ai.tools.github.handle_get_repo", - "category": "Developer Tools", - "parameters": [_p("repo_name", required=True, description="Repository (owner/name)")], - }, - { - "tool_name": "github_create_issue", - "description": "Create a GitHub issue. Requires GITHUB_ACCESS_TOKEN env var.", - "function_path": "huf.ai.tools.github.handle_create_issue", - "category": "Developer Tools", - "parameters": [ - _p("repo_name", required=True, description="Repository (owner/name)"), - _p("title", required=True, description="Issue title"), - _p("body", description="Issue body"), - ], - }, - { - "tool_name": "github_create_pr", - "description": "Create a GitHub pull request. Requires GITHUB_ACCESS_TOKEN env var.", - "function_path": "huf.ai.tools.github.handle_create_pull_request", - "category": "Developer Tools", - "parameters": [ - _p("repo_name", required=True, description="Repository (owner/name)"), - _p("title", required=True, description="PR title"), - _p("body", description="PR description"), - _p("head", required=True, description="Head branch"), - _p("base", required=True, description="Base branch"), - ], - }, - { - "tool_name": "github_get_file", - "description": "Get file content from a GitHub repository. Requires GITHUB_ACCESS_TOKEN env var.", - "function_path": "huf.ai.tools.github.handle_get_file_content", - "category": "Developer Tools", - "parameters": [ - _p("repo_name", required=True, description="Repository (owner/name)"), - _p("path", required=True, description="File path in repository"), - ], - }, - { - "tool_name": "github_search_code", - "description": "Search code across GitHub. Requires GITHUB_ACCESS_TOKEN env var.", - "function_path": "huf.ai.tools.github.handle_search_code", - "category": "Developer Tools", - "parameters": [_p("query", required=True, description="Code search query")], - }, -] - -RECIPIENT_TOOLS = [ - { - "tool_name": "get_integration_recipient", - "description": ( - "Look up a named recipient's service-specific ID from Integration Settings. " - "Use this before sending a message to resolve a human name (e.g. 'John Doe', 'Sales Team') " - "to the correct Telegram Chat ID, Slack User/Channel ID, Discord Channel ID, etc. " - "Call this tool first, then pass the returned recipient_id to the relevant send tool." - ), - "function_path": "huf.ai.tools.recipient.handle_get_recipient", - "category": "Communication Tools", - "parameters": [ - _p("service", required=True, description="The service name, e.g. 'telegram', 'slack', 'discord'"), - _p("recipient_name", required=True, description="Human-friendly recipient name as stored in Integration Settings, e.g. 'John Doe'"), - ], - }, -] # --------------------------------------------------------------------------- -# CRM Tools -# --------------------------------------------------------------------------- - -CRM_TOOLS = [ - { - "tool_name": "crm_get_leads", - "description": "List CRM leads with optional filters (status, assigned_to, search). Returns paginated list with key fields.", - "function_path": "huf.ai.tools.crm.handle_get_leads", - "category": "Frappe CRM Tools", - "parameters": [ - _p("status", description="Filter by lead status"), - _p("assigned_to", description="Filter by lead owner email"), - _p("search", description="Search across first_name, last_name, email, organization"), - _p("limit", type="integer", description="Max leads to fetch (default 20)"), - _p("offset", type="integer", description="Offset for pagination (default 0)"), - ], - }, - { - "tool_name": "crm_get_lead", - "description": "Get a single CRM lead by name/ID with all fields.", - "function_path": "huf.ai.tools.crm.handle_get_lead", - "category": "Frappe CRM Tools", - "parameters": [ - _p("name", required=True, description="Lead ID/name"), - ], - }, - { - "tool_name": "crm_create_lead", - "description": "Create a new CRM lead. Optionally provide notes to create a linked note.", - "function_path": "huf.ai.tools.crm.handle_create_lead", - "category": "Frappe CRM Tools", - "parameters": [ - _p("first_name", required=True, description="First name of the lead"), - _p("last_name", description="Last name of the lead"), - _p("email", description="Email address"), - _p("mobile_no", description="Mobile number"), - _p("lead_owner", description="User email who owns this lead"), - _p("source", description="Lead source"), - _p("organization", description="Organization/company name"), - _p("notes", description="Additional notes (creates a linked FCRM Note)"), - ], - }, - { - "tool_name": "crm_update_lead", - "description": "Update fields on an existing CRM lead.", - "function_path": "huf.ai.tools.crm.handle_update_lead", - "category": "Frappe CRM Tools", - "parameters": [ - _p("name", required=True, description="Lead ID/name"), - _p("lead_name", description="Computed lead name (auto-generated if not set)"), - _p("status", description="Lead status"), - _p("lead_owner", description="Lead owner email"), - _p("first_name", description="First name"), - _p("last_name", description="Last name"), - _p("email", description="Email"), - _p("mobile_no", description="Mobile number"), - _p("organization", description="Organization"), - _p("website", description="Website"), - _p("job_title", description="Job title"), - _p("industry", description="Industry"), - _p("source", description="Source"), - _p("territory", description="Territory"), - _p("details", description="Details / description"), - _p("converted", type="Check", description="Mark as converted"), - _p("lost_reason", description="Lost reason"), - _p("lost_notes", description="Lost notes"), - ], - }, - { - "tool_name": "crm_get_deals", - "description": "List CRM deals with optional filters (status, assigned_to, organization, search).", - "function_path": "huf.ai.tools.crm.handle_get_deals", - "category": "Frappe CRM Tools", - "parameters": [ - _p("status", description="Filter by deal status"), - _p("assigned_to", description="Filter by deal owner email"), - _p("organization", description="Filter by organization name"), - _p("search", description="Search across organization and lead name"), - _p("limit", type="integer", description="Max deals to fetch (default 20)"), - _p("offset", type="integer", description="Offset for pagination (default 0)"), - ], - }, - { - "tool_name": "crm_create_deal", - "description": "Create a CRM deal from a lead or standalone. Provide either lead or organization.", - "function_path": "huf.ai.tools.crm.handle_create_deal", - "category": "Frappe CRM Tools", - "parameters": [ - _p("lead", description="Lead ID to convert into a deal"), - _p("organization", description="Organization name (required if lead is not provided)"), - _p("deal_owner", description="Deal owner email"), - _p("status", description="Deal status"), - _p("deal_value", type="number", description="Deal value amount"), - _p("probability", type="number", description="Probability of closing (0-100)"), - _p("expected_closure_date", description="Expected closure date (YYYY-MM-DD)"), - _p("next_step", description="Next step description"), - _p("currency", description="Currency code"), - ], - }, - { - "tool_name": "crm_update_deal", - "description": "Update an existing CRM deal (status, value, probability, close_date, etc.).", - "function_path": "huf.ai.tools.crm.handle_update_deal", - "category": "Frappe CRM Tools", - "parameters": [ - _p("name", required=True, description="Deal ID/name"), - _p("status", description="Deal status"), - _p("deal_value", type="number", description="Deal value"), - _p("probability", type="number", description="Probability"), - _p("expected_closure_date", description="Expected closure date"), - _p("closed_date", description="Actual closed date"), - _p("deal_owner", description="Deal owner email"), - _p("organization", description="Organization"), - _p("next_step", description="Next step"), - _p("currency", description="Currency"), - _p("lost_reason", description="Lost reason"), - _p("lost_notes", description="Lost notes"), - ], - }, - { - "tool_name": "crm_add_note", - "description": "Add a note to a CRM lead or deal.", - "function_path": "huf.ai.tools.crm.handle_add_note", - "category": "Frappe CRM Tools", - "parameters": [ - _p("doctype", required=True, description="Target DocType: CRM Lead or CRM Deal"), - _p("docname", required=True, description="Target document name/ID"), - _p("content", required=True, description="Note content"), - _p("title", description="Note title (default 'Note')"), - ], - }, - { - "tool_name": "crm_add_task", - "description": "Create a task linked to a CRM lead or deal.", - "function_path": "huf.ai.tools.crm.handle_add_task", - "category": "Frappe CRM Tools", - "parameters": [ - _p("reference_doctype", required=True, description="CRM Lead or CRM Deal"), - _p("reference_docname", required=True, description="Document name/ID"), - _p("title", required=True, description="Task title"), - _p("description", description="Task description"), - _p("assigned_to", description="Assigned user email"), - _p("due_date", description="Due date (YYYY-MM-DD)"), - _p("priority", description="Priority (Low, Medium, High, Urgent)"), - _p("status", description="Status"), - _p("start_date", description="Start date (YYYY-MM-DD)"), - ], - }, - { - "tool_name": "crm_get_contacts", - "description": "List/search CRM contacts linked to deals.", - "function_path": "huf.ai.tools.crm.handle_get_contacts", - "category": "Frappe CRM Tools", - "parameters": [ - _p("search", description="Search across full_name, email, mobile_no"), - _p("deal", description="Filter by parent deal ID"), - _p("limit", type="integer", description="Max contacts to fetch (default 20)"), - _p("offset", type="integer", description="Offset for pagination (default 0)"), - ], - }, -] - -# --------------------------------------------------------------------------- -# Helpdesk Tools +# Communication Tools # --------------------------------------------------------------------------- -HELPDESK_TOOLS = [ - { - "tool_name": "helpdesk_get_tickets", - "description": "List helpdesk tickets with optional filters (status, priority, assigned_to, team, search).", - "function_path": "huf.ai.tools.helpdesk.handle_get_tickets", - "category": "Helpdesk Tools", - "parameters": [ - _p("status", description="Filter by ticket status"), - _p("priority", description="Filter by priority"), - _p("assigned_to", description="Filter by assigned agent user ID"), - _p("team", description="Filter by team (agent_group)"), - _p("ticket_type", description="Filter by ticket type"), - _p("search", description="Search across subject and raised_by"), - _p("limit", type="integer", description="Max tickets to fetch (default 20)"), - _p("offset", type="integer", description="Offset for pagination (default 0)"), - ], - }, - { - "tool_name": "helpdesk_get_ticket", - "description": "Get a single helpdesk ticket by ID with comments.", - "function_path": "huf.ai.tools.helpdesk.handle_get_ticket", - "category": "Helpdesk Tools", - "parameters": [ - _p("name", required=True, description="Ticket ID/name"), - ], - }, - { - "tool_name": "helpdesk_create_ticket", - "description": "Create a new helpdesk support ticket.", - "function_path": "huf.ai.tools.helpdesk.handle_create_ticket", - "category": "Helpdesk Tools", - "parameters": [ - _p("subject", required=True, description="Ticket subject"), - _p("description", description="Ticket description"), - _p("raised_by", description="Email of the requester"), - _p("customer", description="Customer ID (HD Customer)"), - _p("contact", description="Contact ID (Contact)"), - _p("priority", description="Priority"), - _p("ticket_type", description="Ticket type"), - _p("team", description="Team to assign (agent_group)"), - ], - }, - { - "tool_name": "helpdesk_update_ticket", - "description": "Update an existing helpdesk ticket (status, priority, assigned_to, team).", - "function_path": "huf.ai.tools.helpdesk.handle_update_ticket", - "category": "Helpdesk Tools", - "parameters": [ - _p("name", required=True, description="Ticket ID/name"), - _p("status", description="Ticket status"), - _p("priority", description="Priority"), - _p("assigned_to", description="Agent user ID to assign"), - _p("team", description="Team (agent_group)"), - _p("ticket_type", description="Ticket type"), - _p("subject", description="Subject"), - _p("description", description="Description"), - _p("resolution_details", description="Resolution details"), - _p("contact", description="Contact"), - _p("customer", description="Customer"), - ], - }, - { - "tool_name": "helpdesk_add_comment", - "description": "Add a comment/reply to a helpdesk ticket.", - "function_path": "huf.ai.tools.helpdesk.handle_add_comment", - "category": "Helpdesk Tools", - "parameters": [ - _p("ticket_id", required=True, description="Ticket ID"), - _p("content", required=True, description="Comment content"), - _p("commented_by", description="User ID (defaults to current user)"), - ], - }, - { - "tool_name": "helpdesk_get_agents", - "description": "List helpdesk agents.", - "function_path": "huf.ai.tools.helpdesk.handle_get_agents", - "category": "Helpdesk Tools", - "parameters": [ - _p("is_active", type="Check", description="Filter by active status"), - _p("search", description="Search by agent name"), - _p("limit", type="integer", description="Max agents to fetch (default 20)"), - _p("offset", type="integer", description="Offset for pagination (default 0)"), - ], - }, - { - "tool_name": "helpdesk_get_teams", - "description": "List helpdesk teams.", - "function_path": "huf.ai.tools.helpdesk.handle_get_teams", - "category": "Helpdesk Tools", - "parameters": [ - _p("search", description="Search by team name"), - _p("limit", type="integer", description="Max teams to fetch (default 20)"), - _p("offset", type="integer", description="Offset for pagination (default 0)"), - ], - }, - { - "tool_name": "helpdesk_assign_ticket", - "description": "Assign a helpdesk ticket to an agent.", - "function_path": "huf.ai.tools.helpdesk.handle_assign_ticket", - "category": "Helpdesk Tools", - "parameters": [ - _p("ticket_id", required=True, description="Ticket ID"), - _p("agent_id", description="Agent user ID (defaults to current user)"), - ], - }, -] +SLACK_TOOLS = [{ + "tool_name": "slack", + "description": "Interact with Slack workspace. Actions: send_message (channel, text), reply_thread (channel, text, thread_ts), list_channels, get_history (channel, limit), search_messages (query, limit), list_users (limit).", + "function_path": "huf.ai.tools.slack.handle_action", + "category": "Communication Tools", + "parameters": [ + _action("send_message|reply_thread|list_channels|get_history|search_messages|list_users"), + _p("channel", description="Channel ID or name"), + _p("text", description="Message text"), + _p("thread_ts", description="Parent message timestamp (for reply_thread)"), + _p("query", description="Search query (for search_messages)"), + _p("limit", type="integer", description="Max results"), + ], +}] -# --------------------------------------------------------------------------- -# Raven Tools -# --------------------------------------------------------------------------- +DISCORD_TOOLS = [{ + "tool_name": "discord", + "description": "Interact with Discord. Actions: send_message (channel_id, message), get_messages (channel_id, limit), list_channels (guild_id), delete_message (channel_id, message_id).", + "function_path": "huf.ai.tools.discord.handle_action", + "category": "Communication Tools", + "parameters": [ + _action("send_message|get_messages|list_channels|delete_message"), + _p("channel_id", description="Discord channel ID"), + _p("guild_id", description="Discord server (guild) ID"), + _p("message", description="Message text"), + _p("message_id", description="Message ID to delete"), + _p("limit", type="integer", description="Max messages"), + ], +}] -RAVEN_TOOLS = [ - { - "tool_name": "raven_send_message", - "description": "Send a message to a Raven channel by channel_id or channel_name.", - "function_path": "huf.ai.tools.raven.handle_send_message", - "category": "Raven Tools", - "parameters": [ - _p("channel_id", description="Raven Channel ID"), - _p("channel_name", description="Raven Channel name (alternative to channel_id)"), - _p("text", required=True, description="Message text"), - _p("message_type", description="Message type: Text, Image, File, Poll, System (default Text)"), - _p("is_reply", type="Check", description="Whether this is a reply"), - _p("linked_message", description="Message ID being replied to"), - ], - }, - { - "tool_name": "raven_get_messages", - "description": "Get recent messages from a Raven channel with pagination.", - "function_path": "huf.ai.tools.raven.handle_get_messages", - "category": "Raven Tools", - "parameters": [ - _p("channel_id", description="Raven Channel ID"), - _p("channel_name", description="Raven Channel name (alternative to channel_id)"), - _p("limit", type="integer", description="Max messages to fetch (default 20)"), - _p("before_message_id", description="Fetch messages before this message ID"), - ], - }, - { - "tool_name": "raven_list_channels", - "description": "List all Raven channels. Optionally filter by type (public, private, dm).", - "function_path": "huf.ai.tools.raven.handle_list_channels", - "category": "Raven Tools", - "parameters": [ - _p("channel_type", description="Filter by type: public, private, dm"), - _p("limit", type="integer", description="Max channels to fetch (default 50)"), - _p("offset", type="integer", description="Offset for pagination (default 0)"), - ], - }, - { - "tool_name": "raven_get_channel_members", - "description": "Get members of a Raven channel.", - "function_path": "huf.ai.tools.raven.handle_get_channel_members", - "category": "Raven Tools", - "parameters": [ - _p("channel_id", description="Raven Channel ID"), - _p("channel_name", description="Raven Channel name (alternative to channel_id)"), - _p("limit", type="integer", description="Max members to fetch (default 100)"), - _p("offset", type="integer", description="Offset for pagination (default 0)"), - ], - }, - { - "tool_name": "raven_create_channel", - "description": "Create a new Raven channel and optionally add members.", - "function_path": "huf.ai.tools.raven.handle_create_channel", - "category": "Raven Tools", - "parameters": [ - _p("channel_name", required=True, description="Name of the new channel"), - _p("channel_type", description="Type: Public, Private, Open (default Public)"), - _p("channel_description", description="Channel description"), - _p("workspace", description="Workspace ID"), - _p("members", description="List of Raven User IDs to add as members"), - ], - }, - { - "tool_name": "raven_search_messages", - "description": "Search messages across Raven channels or within a specific channel.", - "function_path": "huf.ai.tools.raven.handle_search_messages", - "category": "Raven Tools", - "parameters": [ - _p("query", required=True, description="Search text"), - _p("channel_id", description="Restrict search to a specific channel ID"), - _p("channel_name", description="Restrict search to a specific channel name"), - _p("limit", type="integer", description="Max results (default 20)"), - _p("offset", type="integer", description="Offset for pagination (default 0)"), - ], - }, -] +TELEGRAM_TOOLS = [{ + "tool_name": "telegram", + "description": "Send messages via Telegram bot. Actions: send_message (chat_id, message).", + "function_path": "huf.ai.tools.telegram.handle_action", + "category": "Communication Tools", + "parameters": [ + _action("send_message"), + _p("chat_id", description="Telegram chat ID"), + _p("message", description="Message text"), + ], +}] +GITHUB_TOOLS = [{ + "tool_name": "github", + "description": "Interact with GitHub. Actions: list_repos, get_repo (repo_name), create_issue (repo_name, title, body), create_pr (repo_name, title, head, base, body), get_file (repo_name, path), search_code (query).", + "function_path": "huf.ai.tools.github.handle_action", + "category": "Developer Tools", + "parameters": [ + _action("list_repos|get_repo|create_issue|create_pr|get_file|search_code"), + _p("repo_name", description="Repository (owner/name)"), + _p("title", description="Issue or PR title"), + _p("body", description="Issue or PR body"), + _p("head", description="Head branch (for create_pr)"), + _p("base", description="Base branch (for create_pr)"), + _p("path", description="File path in repo (for get_file)"), + _p("query", description="Search query (for search_code)"), + ], +}] -# --------------------------------------------------------------------------- -# ERPNext Tools -# --------------------------------------------------------------------------- +RECIPIENT_TOOLS = [{ + "tool_name": "get_integration_recipient", + "description": "Look up a named recipient's service-specific ID (Slack user/channel, Telegram chat, Discord channel) from Integration Settings. Use before sending messages to resolve human names to IDs.", + "function_path": "huf.ai.tools.recipient.handle_get_recipient", + "category": "Communication Tools", + "parameters": [ + _p("service", required=True, description="Service name: telegram, slack, discord"), + _p("recipient_name", required=True, description="Human-friendly name as stored in Integration Settings"), + ], +}] -ERPNEXT_TOOLS = [ - { - "tool_name": "erpnext_get_sales_invoices", - "description": "List ERPNext sales invoices with optional filters (customer, status, date range). Returns key fields including grand_total and outstanding_amount.", - "function_path": "huf.ai.tools.erpnext.handle_get_sales_invoices", - "category": "ERPNext Tools", - "parameters": [ - _p("customer", description="Filter by customer ID"), - _p("status", description="Filter by status: Draft, Submitted, Paid, Overdue, Return, Cancelled"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("limit", type="integer", description="Max invoices to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_get_sales_invoice", - "description": "Get a single ERPNext sales invoice by name with full details including items child table.", - "function_path": "huf.ai.tools.erpnext.handle_get_sales_invoice", - "category": "ERPNext Tools", - "parameters": [ - _p("name", required=True, description="Sales Invoice ID/name"), - ], - }, - { - "tool_name": "erpnext_create_sales_invoice", - "description": "Create a draft ERPNext sales invoice. Provide customer and line items.", - "function_path": "huf.ai.tools.erpnext.handle_create_sales_invoice", - "category": "ERPNext Tools", - "parameters": [ - _p("customer", required=True, description="Customer ID"), - _p("company", description="Company name"), - _p("posting_date", description="Invoice date (YYYY-MM-DD)"), - _p("items", type="json", description="List of line items: [{item_code, qty, rate}]"), - ], - }, - { - "tool_name": "erpnext_get_purchase_invoices", - "description": "List ERPNext purchase invoices with optional filters (supplier, status, date range).", - "function_path": "huf.ai.tools.erpnext.handle_get_purchase_invoices", - "category": "ERPNext Tools", - "parameters": [ - _p("supplier", description="Filter by supplier ID"), - _p("status", description="Filter by status: Draft, Submitted, Paid, Overdue, Cancelled"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("limit", type="integer", description="Max invoices to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_get_purchase_invoice", - "description": "Get a single ERPNext purchase invoice by name with full details including items child table.", - "function_path": "huf.ai.tools.erpnext.handle_get_purchase_invoice", - "category": "ERPNext Tools", - "parameters": [ - _p("name", required=True, description="Purchase Invoice ID/name"), - ], - }, - { - "tool_name": "erpnext_get_payments", - "description": "List ERPNext payment entries with optional filters (party_type, party, payment_type, date range).", - "function_path": "huf.ai.tools.erpnext.handle_get_payments", - "category": "ERPNext Tools", - "parameters": [ - _p("party_type", description="Filter by party type: Customer, Supplier"), - _p("party", description="Filter by party name/ID"), - _p("payment_type", description="Filter by payment type: Receive, Pay, Internal Transfer"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("limit", type="integer", description="Max payments to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_create_payment", - "description": "Create a draft ERPNext payment entry. Optionally link to a Sales or Purchase Invoice.", - "function_path": "huf.ai.tools.erpnext.handle_create_payment", - "category": "ERPNext Tools", - "parameters": [ - _p("payment_type", required=True, description="Payment type: Receive, Pay, Internal Transfer"), - _p("party_type", required=True, description="Party type: Customer, Supplier"), - _p("party", required=True, description="Party name/ID"), - _p("company", description="Company name"), - _p("posting_date", description="Payment date (YYYY-MM-DD)"), - _p("paid_amount", required=True, description="Amount paid"), - _p("mode_of_payment", description="Mode of payment"), - _p("paid_from", description="Paid from account"), - _p("paid_to", description="Paid to account"), - _p("invoice_name", description="Sales or Purchase Invoice to link as reference"), - ], - }, - { - "tool_name": "erpnext_get_quotations", - "description": "List ERPNext quotations with optional filters (party_name, status, date range).", - "function_path": "huf.ai.tools.erpnext.handle_get_quotations", - "category": "ERPNext Tools", - "parameters": [ - _p("party_name", description="Filter by party name/ID"), - _p("status", description="Filter by quotation status"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("limit", type="integer", description="Max quotations to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_create_quotation", - "description": "Create a draft ERPNext quotation. Provide quotation_to, party_name, and line items.", - "function_path": "huf.ai.tools.erpnext.handle_create_quotation", - "category": "ERPNext Tools", - "parameters": [ - _p("quotation_to", required=True, description="Quotation to: Customer or Lead"), - _p("party_name", required=True, description="Customer or Lead ID"), - _p("company", description="Company name"), - _p("transaction_date", description="Quotation date (YYYY-MM-DD)"), - _p("valid_till", description="Valid until date (YYYY-MM-DD)"), - _p("items", type="json", description="List of line items: [{item_code, qty, rate}]"), - ], - }, - { - "tool_name": "erpnext_get_customers", - "description": "List/search ERPNext customers with optional filters (customer_group, territory, search query).", - "function_path": "huf.ai.tools.erpnext.handle_get_customers", - "category": "ERPNext Tools", - "parameters": [ - _p("search", description="Search across name, customer_name, or customer_group"), - _p("customer_group", description="Filter by customer group"), - _p("territory", description="Filter by territory"), - _p("limit", type="integer", description="Max customers to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_get_customer", - "description": "Get a single ERPNext customer by name with address and contact details.", - "function_path": "huf.ai.tools.erpnext.handle_get_customer", - "category": "ERPNext Tools", - "parameters": [ - _p("name", required=True, description="Customer ID/name"), - ], - }, - { - "tool_name": "erpnext_get_account_ledger", - "description": "Query GL entries for an account with running balance. GL Entry is read-only.", - "function_path": "huf.ai.tools.erpnext.handle_get_account_ledger", - "category": "ERPNext Tools", - "parameters": [ - _p("account", required=True, description="Account name/ID"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("party_type", description="Filter by party type"), - _p("party", description="Filter by party name/ID"), - _p("limit", type="integer", description="Max entries to fetch (default 50)"), - ], - }, - { - "tool_name": "erpnext_create_journal_entry", - "description": "Create a draft ERPNext journal entry. Total debit must equal total credit.", - "function_path": "huf.ai.tools.erpnext.handle_create_journal_entry", - "category": "ERPNext Tools", - "parameters": [ - _p("voucher_type", description="Voucher type: Journal Entry, Contra Entry, etc."), - _p("posting_date", required=True, description="Posting date (YYYY-MM-DD)"), - _p("company", required=True, description="Company name"), - _p("user_remark", description="User remark / narration"), - _p("accounts", type="json", required=True, description="Account lines: [{account, debit_in_account_currency, credit_in_account_currency, party_type, party, cost_center}]"), - ], - }, - { - "tool_name": "erpnext_get_rfqs", - "description": "List ERPNext requests for quotation with optional filters (status, date range).", - "function_path": "huf.ai.tools.erpnext.handle_get_rfqs", - "category": "ERPNext Tools", - "parameters": [ - _p("status", description="Filter by RFQ status"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("limit", type="integer", description="Max RFQs to fetch (default 20)"), - ], - }, -] +CRM_TOOLS = [{ + "tool_name": "frappe_crm", + "description": "Manage Frappe CRM (standalone app). Actions: list_leads (status, assigned_to, search, limit), get_lead (name), create_lead (first_name required; last_name, email, mobile_no, lead_owner, source, organization, notes), update_lead (name required; any field), list_deals (status, deal_owner, search, limit), get_deal (name), create_deal (organization; or lead to copy from), update_deal (name required; status, deal_value, probability, expected_closure_date), add_note (doctype, docname, content, title), add_task (title, reference_doctype, reference_docname; assigned_to, due_date, priority), list_contacts (search, limit).", + "function_path": "huf.ai.tools.crm.handle_action", + "category": "Frappe CRM Tools", + "parameters": [ + _action("list_leads|get_lead|create_lead|update_lead|list_deals|get_deal|create_deal|update_deal|add_note|add_task|list_contacts"), + _p("name", description="Document name/ID"), + _p("first_name", description="Lead first name"), + _p("last_name", description="Lead last name"), + _p("email", description="Email address"), + _p("mobile_no", description="Mobile number"), + _p("lead_owner", description="Assigned user email"), + _p("organization", description="Company/organization name"), + _p("status", description="Status filter or value to set"), + _p("search", description="Search query"), + _p("doctype", description="DocType for note/task (CRM Lead or CRM Deal)"), + _p("docname", description="Document name for note/task"), + _p("content", description="Note content"), + _p("title", description="Note or task title"), + _p("deal_value", type="number", description="Deal value amount"), + _p("probability", type="integer", description="Win probability 0-100"), + _p("limit", type="integer", description="Max results"), + ], +}] -# --------------------------------------------------------------------------- -# ERPNext CRM Tools -# --------------------------------------------------------------------------- +HELPDESK_TOOLS = [{ + "tool_name": "helpdesk", + "description": "Manage Frappe Helpdesk tickets. Actions: list_tickets (status, priority, team, search, limit), get_ticket (ticket_id — includes comments), create_ticket (subject required; description, customer, priority, type, team), update_ticket (ticket_id required; status, priority, team, description, assigned_to), add_comment (ticket_id, content), list_agents (limit), list_teams (limit), assign_ticket (ticket_id, agent_id).", + "function_path": "huf.ai.tools.helpdesk.handle_action", + "category": "Helpdesk Tools", + "parameters": [ + _action("list_tickets|get_ticket|create_ticket|update_ticket|add_comment|list_agents|list_teams|assign_ticket"), + _p("ticket_id", description="Ticket ID (HD-XXXXX)"), + _p("subject", description="Ticket subject"), + _p("description", description="Ticket description or updated text"), + _p("status", description="Status: Open, Replied, Resolved, Closed"), + _p("priority", description="Priority: Low, Medium, High, Urgent"), + _p("team", description="Team name"), + _p("customer", description="Customer name"), + _p("content", description="Comment text"), + _p("agent_id", description="Agent user ID for assignment"), + _p("search", description="Search query"), + _p("limit", type="integer", description="Max results"), + ], +}] -ERPNEXT_CRM_TOOLS = [ - { - "tool_name": "erpnext_crm_get_leads", - "description": "List ERPNext leads with optional filters (status, lead_owner, search). Returns key fields.", - "function_path": "huf.ai.tools.erpnext_crm.handle_get_leads", - "category": "ERPNext CRM Tools", - "parameters": [ - _p("status", description="Filter by lead status"), - _p("lead_owner", description="Filter by lead owner email"), - _p("search", description="Search across lead_name, company_name, email_id"), - _p("limit", type="integer", description="Max leads to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_crm_get_lead", - "description": "Get a single ERPNext lead by name with all fields.", - "function_path": "huf.ai.tools.erpnext_crm.handle_get_lead", - "category": "ERPNext CRM Tools", - "parameters": [ - _p("name", required=True, description="Lead ID/name"), - ], - }, - { - "tool_name": "erpnext_crm_create_lead", - "description": "Create a new ERPNext lead.", - "function_path": "huf.ai.tools.erpnext_crm.handle_create_lead", - "category": "ERPNext CRM Tools", - "parameters": [ - _p("lead_name", required=True, description="Full name of the lead"), - _p("company_name", description="Company name"), - _p("email_id", description="Email address"), - _p("mobile_no", description="Mobile number"), - _p("phone", description="Phone number"), - _p("lead_owner", description="User email who owns this lead"), - _p("status", description="Lead status (default: Lead)"), - _p("type", description="Lead type"), - _p("market_segment", description="Market segment"), - _p("industry", description="Industry"), - _p("territory", description="Territory"), - _p("website", description="Website URL"), - ], - }, - { - "tool_name": "erpnext_crm_update_lead", - "description": "Update fields on an existing ERPNext lead.", - "function_path": "huf.ai.tools.erpnext_crm.handle_update_lead", - "category": "ERPNext CRM Tools", - "parameters": [ - _p("name", required=True, description="Lead ID/name"), - _p("status", description="Lead status"), - _p("lead_owner", description="Lead owner email"), - _p("email_id", description="Email address"), - _p("mobile_no", description="Mobile number"), - _p("territory", description="Territory"), - _p("qualification_status", description="Qualification status"), - ], - }, - { - "tool_name": "erpnext_crm_get_opportunities", - "description": "List ERPNext opportunities with optional filters (status, party_name, expected closing from date).", - "function_path": "huf.ai.tools.erpnext_crm.handle_get_opportunities", - "category": "ERPNext CRM Tools", - "parameters": [ - _p("status", description="Filter by opportunity status"), - _p("party_name", description="Filter by party name/ID"), - _p("from_date", description="Expected closing from date (YYYY-MM-DD)"), - _p("limit", type="integer", description="Max opportunities to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_crm_create_opportunity", - "description": "Create a new ERPNext opportunity linked to a Customer or Lead.", - "function_path": "huf.ai.tools.erpnext_crm.handle_create_opportunity", - "category": "ERPNext CRM Tools", - "parameters": [ - _p("opportunity_from", required=True, description="Opportunity from: Customer or Lead"), - _p("party_name", required=True, description="Customer or Lead ID"), - _p("title", description="Opportunity title"), - _p("opportunity_type", description="Opportunity type"), - _p("expected_closing", description="Expected closing date (YYYY-MM-DD)"), - _p("opportunity_amount", type="number", description="Opportunity amount"), - _p("sales_stage", description="Sales stage"), - _p("probability", type="number", description="Probability (0-100)"), - _p("currency", description="Currency code"), - ], - }, - { - "tool_name": "erpnext_crm_update_opportunity", - "description": "Update fields on an existing ERPNext opportunity.", - "function_path": "huf.ai.tools.erpnext_crm.handle_update_opportunity", - "category": "ERPNext CRM Tools", - "parameters": [ - _p("name", required=True, description="Opportunity ID/name"), - _p("status", description="Opportunity status"), - _p("opportunity_amount", type="number", description="Opportunity amount"), - _p("sales_stage", description="Sales stage"), - _p("probability", type="number", description="Probability"), - _p("expected_closing", description="Expected closing date (YYYY-MM-DD)"), - _p("order_lost_reason", description="Reason if order lost"), - ], - }, -] +RAVEN_TOOLS = [{ + "tool_name": "raven", + "description": "Interact with Frappe Raven internal messaging. Actions: send_message (channel_id or channel_name, text), get_messages (channel_id or channel_name, limit, before_message_id), list_channels (channel_type, limit), get_members (channel_id or channel_name), create_channel (channel_name, type, channel_description, members list), search_messages (query; channel_id or channel_name, limit).", + "function_path": "huf.ai.tools.raven.handle_action", + "category": "Raven Tools", + "parameters": [ + _action("send_message|get_messages|list_channels|get_members|create_channel|search_messages"), + _p("channel_id", description="Raven channel ID"), + _p("channel_name", description="Channel name (alternative to channel_id)"), + _p("text", description="Message text"), + _p("channel_type", description="Channel type filter: Public, Private, Open"), + _p("members", description="JSON list of user IDs (for create_channel)"), + _p("channel_description", description="Channel description"), + _p("query", description="Search text"), + _p("before_message_id", description="Pagination cursor"), + _p("limit", type="integer", description="Max results"), + ], +}] -# --------------------------------------------------------------------------- -# ERPNext Inventory Tools -# --------------------------------------------------------------------------- +ERPNEXT_TOOLS = [{ + "tool_name": "erpnext", + "description": "Manage ERPNext transactions and accounting. Actions: list_sales_invoices (customer, status, from_date, to_date, limit), get_sales_invoice (name), create_sales_invoice (customer required, items list [{item_code,qty,rate}], company, posting_date), list_purchase_invoices (supplier, status, from_date, to_date, limit), get_purchase_invoice (name), list_payments (party_type, party, payment_type, from_date, to_date, limit), create_payment (payment_type required [Receive/Pay], party_type required, party required, paid_amount required; mode_of_payment, invoice_name), list_customers (search, customer_group, limit), get_customer (name), list_quotations (party_name, status, from_date, limit), create_quotation (quotation_to required [Customer/Lead], party_name required, items list, transaction_date, valid_till), list_rfqs (status, from_date, limit), get_ledger (account required, from_date, to_date, party_type, party, limit), create_journal_entry (voucher_type, posting_date, company, user_remark, accounts list [{account, debit_in_account_currency, credit_in_account_currency}]).", + "function_path": "huf.ai.tools.erpnext.handle_action", + "category": "ERPNext Tools", + "parameters": [ + _action("list_sales_invoices|get_sales_invoice|create_sales_invoice|list_purchase_invoices|get_purchase_invoice|list_payments|create_payment|list_customers|get_customer|list_quotations|create_quotation|list_rfqs|get_ledger|create_journal_entry"), + _p("name", description="Document name/ID"), + _p("customer", description="Customer name"), + _p("supplier", description="Supplier name"), + _p("party_type", description="Party type: Customer or Supplier"), + _p("party", description="Party name"), + _p("payment_type", description="Payment type: Receive, Pay, Internal Transfer"), + _p("paid_amount", type="number", description="Payment amount"), + _p("invoice_name", description="Invoice to link payment to"), + _p("mode_of_payment", description="Mode of payment"), + _p("account", description="Account name (for get_ledger)"), + _p("status", description="Document status filter"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("company", description="Company name (defaults to user default)"), + _p("posting_date", description="Posting date"), + _p("items", description="JSON list of items [{item_code, qty, rate}]"), + _p("accounts", description="JSON list of journal accounts [{account, debit_in_account_currency, credit_in_account_currency}]"), + _p("voucher_type", description="Journal voucher type"), + _p("user_remark", description="Journal entry remark"), + _p("quotation_to", description="Quotation for: Customer or Lead"), + _p("party_name", description="Customer/Lead name for quotation"), + _p("search", description="Search query"), + _p("limit", type="integer", description="Max results"), + ], +}] -ERPNEXT_INVENTORY_TOOLS = [ - { - "tool_name": "erpnext_get_items", - "description": "List ERPNext items with optional search across item_code, item_name, and item_group. Filter by item_group, is_stock_item, or disabled status.", - "function_path": "huf.ai.tools.erpnext_inventory.handle_get_items", - "category": "ERPNext Inventory", - "parameters": [ - _p("search", description="Search across item_code, item_name, or item_group"), - _p("item_group", description="Filter by item group"), - _p("is_stock_item", type="Check", description="Filter by stock item flag"), - _p("disabled", type="Check", description="Include disabled items (default 0)"), - _p("limit", type="integer", description="Max items to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_get_item", - "description": "Get a single ERPNext item by name with full details including item_defaults child table.", - "function_path": "huf.ai.tools.erpnext_inventory.handle_get_item", - "category": "ERPNext Inventory", - "parameters": [ - _p("name", required=True, description="Item ID/name (item_code)"), - ], - }, - { - "tool_name": "erpnext_get_item_prices", - "description": "List ERPNext item prices for an item with optional filters (price_list, buying, selling).", - "function_path": "huf.ai.tools.erpnext_inventory.handle_get_item_prices", - "category": "ERPNext Inventory", - "parameters": [ - _p("item_code", description="Filter by item code"), - _p("price_list", description="Filter by price list name"), - _p("buying", type="Check", description="Filter buying prices"), - _p("selling", type="Check", description="Filter selling prices"), - _p("limit", type="integer", description="Max prices to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_get_boms", - "description": "List ERPNext BOMs (Bill of Materials) with optional filters (item, is_active, is_default, company).", - "function_path": "huf.ai.tools.erpnext_inventory.handle_get_boms", - "category": "ERPNext Inventory", - "parameters": [ - _p("item", description="Filter by finished item code"), - _p("is_active", type="Check", description="Filter by active status"), - _p("is_default", type="Check", description="Filter by default status"), - _p("company", description="Filter by company"), - _p("limit", type="integer", description="Max BOMs to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_get_bom", - "description": "Get a single ERPNext BOM by name with items and operations child tables.", - "function_path": "huf.ai.tools.erpnext_inventory.handle_get_bom", - "category": "ERPNext Inventory", - "parameters": [ - _p("name", required=True, description="BOM ID/name"), - ], - }, - { - "tool_name": "erpnext_create_bom", - "description": "Create a draft ERPNext BOM. Provide finished item, quantity, and raw material line items.", - "function_path": "huf.ai.tools.erpnext_inventory.handle_create_bom", - "category": "ERPNext Inventory", - "parameters": [ - _p("item", required=True, description="Finished item code"), - _p("quantity", type="number", description="Quantity to manufacture (default 1)"), - _p("uom", description="Unit of measure"), - _p("company", description="Company name"), - _p("is_default", type="Check", description="Set as default BOM (default 1)"), - _p("items", type="json", description="Raw material lines: [{item_code, qty, uom, rate}]"), - ], - }, - { - "tool_name": "erpnext_get_stock_balance", - "description": "Get current stock balance per item and warehouse from Stock Ledger Entry. Optionally filter by item_code, warehouse, or as_of_date.", - "function_path": "huf.ai.tools.erpnext_inventory.handle_get_stock_balance", - "category": "ERPNext Inventory", - "parameters": [ - _p("item_code", description="Filter by item code"), - _p("warehouse", description="Filter by warehouse"), - _p("as_of_date", description="Balance as of date (YYYY-MM-DD)"), - ], - }, - { - "tool_name": "erpnext_get_stock_movements", - "description": "List ERPNext stock ledger entries with optional filters (item_code, warehouse, date range, voucher_type).", - "function_path": "huf.ai.tools.erpnext_inventory.handle_get_stock_movements", - "category": "ERPNext Inventory", - "parameters": [ - _p("item_code", description="Filter by item code"), - _p("warehouse", description="Filter by warehouse"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("voucher_type", description="Filter by voucher type"), - _p("limit", type="integer", description="Max entries to fetch (default 50)"), - ], - }, - { - "tool_name": "erpnext_get_stock_entries", - "description": "List ERPNext stock entry documents (Material Issue, Receipt, Transfer, Manufacture) with optional filters.", - "function_path": "huf.ai.tools.erpnext_inventory.handle_get_stock_entries", - "category": "ERPNext Inventory", - "parameters": [ - _p("stock_entry_type", description="Filter by type: Material Issue, Material Receipt, Material Transfer, Manufacture"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("docstatus", type="integer", description="Filter by docstatus: 0=Draft, 1=Submitted, 2=Cancelled"), - _p("limit", type="integer", description="Max entries to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_get_warehouses", - "description": "List ERPNext warehouses with optional filters (company, warehouse_type, disabled).", - "function_path": "huf.ai.tools.erpnext_inventory.handle_get_warehouses", - "category": "ERPNext Inventory", - "parameters": [ - _p("company", description="Filter by company"), - _p("warehouse_type", description="Filter by warehouse type"), - _p("disabled", type="Check", description="Include disabled warehouses (default 0)"), - _p("limit", type="integer", description="Max warehouses to fetch (default 50)"), - ], - }, - { - "tool_name": "erpnext_get_delivery_notes", - "description": "List ERPNext delivery notes with optional filters (customer, date range, docstatus).", - "function_path": "huf.ai.tools.erpnext_inventory.handle_get_delivery_notes", - "category": "ERPNext Inventory", - "parameters": [ - _p("customer", description="Filter by customer ID"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("docstatus", type="integer", description="Filter by docstatus"), - _p("limit", type="integer", description="Max notes to fetch (default 20)"), - ], - }, - { - "tool_name": "erpnext_get_purchase_receipts", - "description": "List ERPNext purchase receipts with optional filters (supplier, date range, docstatus).", - "function_path": "huf.ai.tools.erpnext_inventory.handle_get_purchase_receipts", - "category": "ERPNext Inventory", - "parameters": [ - _p("supplier", description="Filter by supplier ID"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("docstatus", type="integer", description="Filter by docstatus"), - _p("limit", type="integer", description="Max receipts to fetch (default 20)"), - ], - }, -] +ERPNEXT_CRM_TOOLS = [{ + "tool_name": "erpnext_crm", + "description": "Manage ERPNext built-in CRM (Lead and Opportunity doctypes — part of ERPNext, different from standalone Frappe CRM). Actions: list_leads (status, lead_owner, search, limit), get_lead (name), create_lead (lead_name required; company_name, email_id, mobile_no, lead_owner, type, industry, territory), update_lead (name required; status, lead_owner, email_id, territory), list_opportunities (status, party_name, from_date, limit), create_opportunity (opportunity_from required [Customer/Lead], party_name required; title, opportunity_type, opportunity_amount, sales_stage, probability, expected_closing), update_opportunity (name required; status, opportunity_amount, sales_stage, probability, expected_closing).", + "function_path": "huf.ai.tools.erpnext_crm.handle_action", + "category": "ERPNext CRM Tools", + "parameters": [ + _action("list_leads|get_lead|create_lead|update_lead|list_opportunities|create_opportunity|update_opportunity"), + _p("name", description="Document name/ID"), + _p("lead_name", description="Lead full name"), + _p("company_name", description="Company name"), + _p("email_id", description="Email address"), + _p("mobile_no", description="Mobile number"), + _p("lead_owner", description="Assigned user email"), + _p("status", description="Status filter or value to set"), + _p("territory", description="Territory"), + _p("industry", description="Industry"), + _p("opportunity_from", description="Opportunity from: Customer or Lead"), + _p("party_name", description="Customer or Lead name"), + _p("opportunity_amount", type="number", description="Opportunity value"), + _p("sales_stage", description="Sales stage"), + _p("probability", type="integer", description="Win probability 0-100"), + _p("expected_closing", description="Expected closing date (YYYY-MM-DD)"), + _p("search", description="Search query"), + _p("from_date", description="Filter from date"), + _p("limit", type="integer", description="Max results"), + ], +}] -# --------------------------------------------------------------------------- -# ERPNext Report Tools -# --------------------------------------------------------------------------- +ERPNEXT_INVENTORY_TOOLS = [{ + "tool_name": "erpnext_inventory", + "description": "Manage ERPNext inventory, items, BOM and stock. Actions: list_items (search, item_group, is_stock_item, limit), get_item (name — item_code), item_prices (item_code, price_list, buying, selling), stock_balance (item_code, warehouse, as_of_date), stock_movements (item_code, warehouse, from_date, to_date, limit), list_stock_entries (stock_entry_type, from_date, to_date, limit), list_warehouses (company, limit), list_delivery_notes (customer, from_date, to_date, limit), list_purchase_receipts (supplier, from_date, to_date, limit), list_boms (item, is_active, is_default, limit), get_bom (name), create_bom (item required, quantity, items list [{item_code, qty, uom, rate}]).", + "function_path": "huf.ai.tools.erpnext_inventory.handle_action", + "category": "ERPNext Inventory", + "parameters": [ + _action("list_items|get_item|item_prices|stock_balance|stock_movements|list_stock_entries|list_warehouses|list_delivery_notes|list_purchase_receipts|list_boms|get_bom|create_bom"), + _p("name", description="Document name or item_code"), + _p("item_code", description="Item code"), + _p("item_group", description="Item group filter"), + _p("is_stock_item", type="integer", description="1 for stock items only, 0 for all"), + _p("price_list", description="Price list name"), + _p("warehouse", description="Warehouse name"), + _p("as_of_date", description="Stock balance as of date (YYYY-MM-DD)"), + _p("stock_entry_type", description="Material Issue, Material Receipt, Material Transfer, Manufacture"), + _p("customer", description="Customer name filter"), + _p("supplier", description="Supplier name filter"), + _p("item", description="Item code for BOM filter"), + _p("is_active", type="integer", description="1 for active BOMs"), + _p("is_default", type="integer", description="1 for default BOMs"), + _p("quantity", type="number", description="BOM quantity"), + _p("items", description="JSON list of BOM items [{item_code, qty, uom, rate}]"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("search", description="Search query"), + _p("company", description="Company name"), + _p("limit", type="integer", description="Max results"), + ], +}] ERPNEXT_REPORT_TOOLS = [ - { - "tool_name": "erpnext_balance_sheet", - "description": "Run ERPNext Balance Sheet report. Key filters: company (required), fiscal_year or from_fiscal_year/to_fiscal_year, periodicity (Monthly/Quarterly/Half-Yearly/Yearly), accumulated_values.", - "function_path": "huf.ai.tools.erpnext_reports.handle_balance_sheet", - "category": "ERPNext Reports", - "parameters": [ - _p("company", required=True, description="Company name"), - _p("fiscal_year", description="Fiscal year"), - _p("from_fiscal_year", description="From fiscal year"), - _p("to_fiscal_year", description="To fiscal year"), - _p("periodicity", description="Monthly, Quarterly, Half-Yearly, or Yearly"), - _p("accumulated_values", type="Check", description="Show accumulated values (default 1)"), - ], - }, - { - "tool_name": "erpnext_profit_and_loss", - "description": "Run ERPNext Profit and Loss Statement report. Key filters: company (required), fiscal_year, periodicity, from_date, to_date.", - "function_path": "huf.ai.tools.erpnext_reports.handle_profit_and_loss", - "category": "ERPNext Reports", - "parameters": [ - _p("company", required=True, description="Company name"), - _p("fiscal_year", description="Fiscal year"), - _p("periodicity", description="Monthly, Quarterly, Half-Yearly, or Yearly"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - ], - }, - { - "tool_name": "erpnext_trial_balance", - "description": "Run ERPNext Trial Balance report. Key filters: company (required), from_date, to_date, show_zero_values.", - "function_path": "huf.ai.tools.erpnext_reports.handle_trial_balance", - "category": "ERPNext Reports", - "parameters": [ - _p("company", required=True, description="Company name"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("show_zero_values", type="Check", description="Show accounts with zero balance (default 0)"), - ], - }, - { - "tool_name": "erpnext_general_ledger", - "description": "Run ERPNext General Ledger report. Key filters: company (required), from_date, to_date, account, party_type, party, voucher_no, group_by, limit.", - "function_path": "huf.ai.tools.erpnext_reports.handle_general_ledger", - "category": "ERPNext Reports", - "parameters": [ - _p("company", required=True, description="Company name"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("account", description="Account name/ID"), - _p("party_type", description="Party type"), - _p("party", description="Party name/ID"), - _p("voucher_no", description="Voucher number"), - _p("group_by", description="Group by Voucher or Group by Account"), - _p("limit", type="integer", description="Max rows to return (default 500)"), - ], - }, - { - "tool_name": "erpnext_accounts_receivable", - "description": "Run ERPNext Accounts Receivable report. Key filters: company (required), report_date, ageing_based_on (Due Date/Posting Date), range1/range2/range3, customer.", - "function_path": "huf.ai.tools.erpnext_reports.handle_accounts_receivable", - "category": "ERPNext Reports", - "parameters": [ - _p("company", required=True, description="Company name"), - _p("report_date", description="Report as-of date (YYYY-MM-DD)"), - _p("ageing_based_on", description="Due Date or Posting Date"), - _p("range1", type="integer", description="Ageing range 1 in days (default 30)"), - _p("range2", type="integer", description="Ageing range 2 in days (default 60)"), - _p("range3", type="integer", description="Ageing range 3 in days (default 90)"), - _p("customer", description="Filter by customer ID"), - _p("payment_terms_template", description="Filter by payment terms template"), - ], - }, - { - "tool_name": "erpnext_accounts_payable", - "description": "Run ERPNext Accounts Payable report. Key filters: company (required), report_date, ageing_based_on, range1/range2/range3, supplier.", - "function_path": "huf.ai.tools.erpnext_reports.handle_accounts_payable", - "category": "ERPNext Reports", - "parameters": [ - _p("company", required=True, description="Company name"), - _p("report_date", description="Report as-of date (YYYY-MM-DD)"), - _p("ageing_based_on", description="Due Date or Posting Date"), - _p("range1", type="integer", description="Ageing range 1 in days (default 30)"), - _p("range2", type="integer", description="Ageing range 2 in days (default 60)"), - _p("range3", type="integer", description="Ageing range 3 in days (default 90)"), - _p("supplier", description="Filter by supplier ID"), - ], - }, - { - "tool_name": "erpnext_bank_reconciliation", - "description": "Run ERPNext Bank Reconciliation Statement report. Key filters: company (required), account (bank account, required), from_date, to_date, include_pos_transactions.", - "function_path": "huf.ai.tools.erpnext_reports.handle_bank_reconciliation", - "category": "ERPNext Reports", - "parameters": [ - _p("company", required=True, description="Company name"), - _p("account", required=True, description="Bank account name/ID"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("include_pos_transactions", type="Check", description="Include POS transactions (default 0)"), - ], - }, - { - "tool_name": "erpnext_sales_register", - "description": "Run ERPNext Sales Register report. Key filters: company, from_date (required), to_date (required), customer, item_code.", - "function_path": "huf.ai.tools.erpnext_reports.handle_sales_register", - "category": "ERPNext Reports", - "parameters": [ - _p("company", description="Company name"), - _p("from_date", required=True, description="Start date (YYYY-MM-DD)"), - _p("to_date", required=True, description="End date (YYYY-MM-DD)"), - _p("customer", description="Filter by customer ID"), - _p("item_code", description="Filter by item code"), - ], - }, - { - "tool_name": "erpnext_sales_order_analysis", - "description": "Run ERPNext Sales Order Analysis report. Key filters: company, from_date, to_date, customer, item_code, status.", - "function_path": "huf.ai.tools.erpnext_reports.handle_sales_order_analysis", - "category": "ERPNext Reports", - "parameters": [ - _p("company", description="Company name"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("customer", description="Filter by customer ID"), - _p("item_code", description="Filter by item code"), - _p("status", description="Filter by status: Draft, To Deliver and Bill, Completed, etc."), - ], - }, - { - "tool_name": "erpnext_customer_acquisition", - "description": "Run ERPNext Customer Acquisition and Loyalty report. Key filters: company, from_date, to_date, customer_group, territory.", - "function_path": "huf.ai.tools.erpnext_reports.handle_customer_acquisition", - "category": "ERPNext Reports", - "parameters": [ - _p("company", description="Company name"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("customer_group", description="Filter by customer group"), - _p("territory", description="Filter by territory"), - ], - }, - { - "tool_name": "erpnext_stock_balance_report", - "description": "Run ERPNext Stock Balance report (native ERPNext report). Key filters: company, from_date, to_date, item_code, warehouse, item_group.", - "function_path": "huf.ai.tools.erpnext_reports.handle_stock_balance_report", - "category": "ERPNext Reports", - "parameters": [ - _p("company", description="Company name"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("item_code", description="Filter by item code"), - _p("warehouse", description="Filter by warehouse"), - _p("item_group", description="Filter by item group"), - ], - }, - { - "tool_name": "erpnext_stock_ledger_report", - "description": "Run ERPNext Stock Ledger report (native ERPNext report). Key filters: company, from_date (required), to_date (required), item_code, warehouse, voucher_no.", - "function_path": "huf.ai.tools.erpnext_reports.handle_stock_ledger_report", - "category": "ERPNext Reports", - "parameters": [ - _p("company", description="Company name"), - _p("from_date", required=True, description="Start date (YYYY-MM-DD)"), - _p("to_date", required=True, description="End date (YYYY-MM-DD)"), - _p("item_code", description="Filter by item code"), - _p("warehouse", description="Filter by warehouse"), - _p("voucher_no", description="Filter by voucher number"), - ], - }, - { - "tool_name": "erpnext_item_wise_sales", - "description": "Run ERPNext Item-wise Sales Register report. Key filters: company, from_date, to_date, item_code, customer.", - "function_path": "huf.ai.tools.erpnext_reports.handle_item_wise_sales", - "category": "ERPNext Reports", - "parameters": [ - _p("company", description="Company name"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("item_code", description="Filter by item code"), - _p("customer", description="Filter by customer ID"), - ], - }, - { - "tool_name": "erpnext_gross_profit", - "description": "Run ERPNext Gross Profit report. Key filters: company, from_date, to_date, group_by (Invoice/Item Code/Item Group/Customer/Customer Group).", - "function_path": "huf.ai.tools.erpnext_reports.handle_gross_profit", - "category": "ERPNext Reports", - "parameters": [ - _p("company", description="Company name"), - _p("from_date", description="Start date (YYYY-MM-DD)"), - _p("to_date", description="End date (YYYY-MM-DD)"), - _p("group_by", description="Group by: Invoice, Item Code, Item Group, Customer, Customer Group"), - ], - }, + { + "tool_name": "erpnext_run_report", + "description": "Run any ERPNext script or query report by name and get results. Use erpnext_list_reports to discover available report names and their modules. Common reports: 'Balance Sheet', 'Profit and Loss Statement', 'Cash Flow', 'General Ledger', 'Accounts Receivable', 'Accounts Payable', 'Stock Balance', 'Stock Ledger', 'Sales Register', 'Purchase Register', 'Sales Analytics', 'Sales Pipeline Analytics', 'Lead Details'. Pass filters as a JSON object with keys like company, from_date, to_date, fiscal_year, etc.", + "function_path": "huf.ai.tools.erpnext_reports.handle_run_report", + "category": "ERPNext Reports", + "parameters": [ + _p("report_name", required=True, description="Exact report name (case-sensitive). Use erpnext_list_reports to find valid names."), + _p("filters", description="JSON object of filter key-value pairs. E.g. {\"company\": \"My Company\", \"from_date\": \"2024-01-01\", \"to_date\": \"2024-12-31\"}"), + ], + }, + { + "tool_name": "erpnext_list_reports", + "description": "List available ERPNext reports by module. Use this to discover report names before calling erpnext_run_report. Available modules: Accounts, Selling, Buying, Stock, Manufacturing, CRM, Helpdesk, Projects, HR.", + "function_path": "huf.ai.tools.erpnext_reports.handle_list_reports", + "category": "ERPNext Reports", + "parameters": [ + _p("module", description="Module to filter by: Accounts, Selling, Buying, Stock, Manufacturing, CRM, Helpdesk, Projects, HR. Leave empty to list all."), + ], + }, ] # --------------------------------------------------------------------------- @@ -1222,16 +282,16 @@ def _p(name, type="string", required=False, description=""): # --------------------------------------------------------------------------- ALL_INTEGRATION_TOOLS = ( - RECIPIENT_TOOLS - + SLACK_TOOLS - + DISCORD_TOOLS - + TELEGRAM_TOOLS - + GITHUB_TOOLS - + CRM_TOOLS - + HELPDESK_TOOLS - + RAVEN_TOOLS - + ERPNEXT_TOOLS - + ERPNEXT_CRM_TOOLS - + ERPNEXT_INVENTORY_TOOLS - + ERPNEXT_REPORT_TOOLS + RECIPIENT_TOOLS + + SLACK_TOOLS + + DISCORD_TOOLS + + TELEGRAM_TOOLS + + GITHUB_TOOLS + + CRM_TOOLS + + HELPDESK_TOOLS + + RAVEN_TOOLS + + ERPNEXT_TOOLS + + ERPNEXT_CRM_TOOLS + + ERPNEXT_INVENTORY_TOOLS + + ERPNEXT_REPORT_TOOLS ) diff --git a/huf/ai/tools/crm.py b/huf/ai/tools/crm.py index f7eb1bfc..5b377c04 100644 --- a/huf/ai/tools/crm.py +++ b/huf/ai/tools/crm.py @@ -22,7 +22,7 @@ def _error(msg): # Leads # --------------------------------------------------------------------------- -def handle_get_leads(**kwargs) -> str: +def _handle_get_leads(**kwargs) -> str: """List leads with optional filters (status, assigned_to, search query).""" if not _crm_installed(): return _error("Frappe CRM app is not installed.") @@ -77,7 +77,7 @@ def handle_get_leads(**kwargs) -> str: return _error(str(e)) -def handle_get_lead(**kwargs) -> str: +def _handle_get_lead(**kwargs) -> str: """Get a single lead by name/ID with all fields.""" if not _crm_installed(): return _error("Frappe CRM app is not installed.") @@ -97,7 +97,7 @@ def handle_get_lead(**kwargs) -> str: return _error(str(e)) -def handle_create_lead(**kwargs) -> str: +def _handle_create_lead(**kwargs) -> str: """Create a new lead.""" if not _crm_installed(): return _error("Frappe CRM app is not installed.") @@ -137,7 +137,7 @@ def handle_create_lead(**kwargs) -> str: return _error(str(e)) -def handle_update_lead(**kwargs) -> str: +def _handle_update_lead(**kwargs) -> str: """Update lead fields.""" if not _crm_installed(): return _error("Frappe CRM app is not installed.") @@ -186,7 +186,7 @@ def handle_update_lead(**kwargs) -> str: # Deals # --------------------------------------------------------------------------- -def handle_get_deals(**kwargs) -> str: +def _handle_get_deals(**kwargs) -> str: """List deals with optional filters.""" if not _crm_installed(): return _error("Frappe CRM app is not installed.") @@ -242,7 +242,26 @@ def handle_get_deals(**kwargs) -> str: return _error(str(e)) -def handle_create_deal(**kwargs) -> str: +def _handle_get_deal(**kwargs) -> str: + """Get a single deal by name/ID with all fields.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + name = kwargs.get("name") or kwargs.get("deal_id") + if not name: + return _error("name or deal_id is required") + if not frappe.db.exists("CRM Deal", name): + return _error(f"Deal {name} not found") + + doc = frappe.get_doc("CRM Deal", name) + return json.dumps({"success": True, "results": doc.as_dict()}) + except Exception as e: + frappe.log_error(f"CRM Get Deal Error: {e}", "CRM Tool") + return _error(str(e)) + + +def _handle_create_deal(**kwargs) -> str: """Create a deal from a lead or standalone.""" if not _crm_installed(): return _error("Frappe CRM app is not installed.") @@ -305,7 +324,7 @@ def handle_create_deal(**kwargs) -> str: return _error(str(e)) -def handle_update_deal(**kwargs) -> str: +def _handle_update_deal(**kwargs) -> str: """Update deal fields.""" if not _crm_installed(): return _error("Frappe CRM app is not installed.") @@ -353,7 +372,7 @@ def handle_update_deal(**kwargs) -> str: # Notes & Tasks # --------------------------------------------------------------------------- -def handle_add_note(**kwargs) -> str: +def _handle_add_note(**kwargs) -> str: """Add a note to a lead or deal.""" if not _crm_installed(): return _error("Frappe CRM app is not installed.") @@ -381,7 +400,7 @@ def handle_add_note(**kwargs) -> str: return _error(str(e)) -def handle_add_task(**kwargs) -> str: +def _handle_add_task(**kwargs) -> str: """Create a task linked to a lead or deal.""" if not _crm_installed(): return _error("Frappe CRM app is not installed.") @@ -418,7 +437,7 @@ def handle_add_task(**kwargs) -> str: # Contacts # --------------------------------------------------------------------------- -def handle_get_contacts(**kwargs) -> str: +def _handle_get_contacts(**kwargs) -> str: """List/search contacts. Optionally filter by deal to return CRM Contacts child rows.""" if not _crm_installed(): return _error("Frappe CRM app is not installed.") @@ -501,3 +520,25 @@ def handle_get_contacts(**kwargs) -> str: except Exception as e: frappe.log_error(f"CRM Get Contacts Error: {e}", "CRM Tool") return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "list_leads": _handle_get_leads, + "get_lead": _handle_get_lead, + "create_lead": _handle_create_lead, + "update_lead": _handle_update_lead, + "list_deals": _handle_get_deals, + "get_deal": _handle_get_deal, + "create_deal": _handle_create_deal, + "update_deal": _handle_update_deal, + "add_note": _handle_add_note, + "add_task": _handle_add_task, + "list_contacts": _handle_get_contacts, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return handler(**kwargs) diff --git a/huf/ai/tools/discord.py b/huf/ai/tools/discord.py index 83878d69..a94f22db 100644 --- a/huf/ai/tools/discord.py +++ b/huf/ai/tools/discord.py @@ -21,7 +21,7 @@ def _get_discord_headers(): } -def handle_send_message(**kwargs) -> str: +def _handle_send_message(**kwargs) -> str: """Send a message to a Discord channel.""" service_name = "discord" try: @@ -52,7 +52,7 @@ def handle_send_message(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_get_channel_messages(**kwargs) -> str: +def _handle_get_channel_messages(**kwargs) -> str: """Get message history of a Discord channel.""" service_name = "discord" try: @@ -87,7 +87,7 @@ def handle_get_channel_messages(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_list_channels(**kwargs) -> str: +def _handle_list_channels(**kwargs) -> str: """List all channels in a Discord server (guild).""" service_name = "discord" try: @@ -120,7 +120,7 @@ def handle_list_channels(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_delete_message(**kwargs) -> str: +def _handle_delete_message(**kwargs) -> str: """Delete a message from a Discord channel.""" service_name = "discord" try: @@ -147,3 +147,18 @@ def handle_delete_message(**kwargs) -> str: frappe.log_error(error_msg, "Discord Tool") update_last_error(service_name, error_msg) return json.dumps({"success": False, "error": str(e)}) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "send_message": _handle_send_message, + "get_messages": _handle_get_channel_messages, + "list_channels": _handle_list_channels, + "delete_message": _handle_delete_message, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return handler(**kwargs) diff --git a/huf/ai/tools/erpnext.py b/huf/ai/tools/erpnext.py index b9888fce..380df387 100644 --- a/huf/ai/tools/erpnext.py +++ b/huf/ai/tools/erpnext.py @@ -26,7 +26,7 @@ def _docstatus_label(ds): # Sales Invoice # --------------------------------------------------------------------------- -def handle_get_sales_invoices(**kwargs) -> str: +def _handle_get_sales_invoices(**kwargs) -> str: """List Sales Invoices with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -86,7 +86,7 @@ def handle_get_sales_invoices(**kwargs) -> str: return _error(str(e)) -def handle_get_sales_invoice(**kwargs) -> str: +def _handle_get_sales_invoice(**kwargs) -> str: """Get a single Sales Invoice by name/ID with all fields including items.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -107,7 +107,7 @@ def handle_get_sales_invoice(**kwargs) -> str: return _error(str(e)) -def handle_create_sales_invoice(**kwargs) -> str: +def _handle_create_sales_invoice(**kwargs) -> str: """Create a draft Sales Invoice.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -148,7 +148,7 @@ def handle_create_sales_invoice(**kwargs) -> str: # Purchase Invoice # --------------------------------------------------------------------------- -def handle_get_purchase_invoices(**kwargs) -> str: +def _handle_get_purchase_invoices(**kwargs) -> str: """List Purchase Invoices with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -210,7 +210,7 @@ def handle_get_purchase_invoices(**kwargs) -> str: return _error(str(e)) -def handle_get_purchase_invoice(**kwargs) -> str: +def _handle_get_purchase_invoice(**kwargs) -> str: """Get a single Purchase Invoice by name/ID with all fields including items.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -235,7 +235,7 @@ def handle_get_purchase_invoice(**kwargs) -> str: # Payment Entry # --------------------------------------------------------------------------- -def handle_get_payments(**kwargs) -> str: +def _handle_get_payments(**kwargs) -> str: """List Payment Entries with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -286,7 +286,7 @@ def handle_get_payments(**kwargs) -> str: return _error(str(e)) -def handle_create_payment(**kwargs) -> str: +def _handle_create_payment(**kwargs) -> str: """Create a draft Payment Entry. Optionally link to an invoice.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -343,7 +343,7 @@ def handle_create_payment(**kwargs) -> str: # Quotation # --------------------------------------------------------------------------- -def handle_get_quotations(**kwargs) -> str: +def _handle_get_quotations(**kwargs) -> str: """List Quotations with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -397,7 +397,7 @@ def handle_get_quotations(**kwargs) -> str: return _error(str(e)) -def handle_create_quotation(**kwargs) -> str: +def _handle_create_quotation(**kwargs) -> str: """Create a draft Quotation.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -441,7 +441,7 @@ def handle_create_quotation(**kwargs) -> str: # Customer # --------------------------------------------------------------------------- -def handle_get_customers(**kwargs) -> str: +def _handle_get_customers(**kwargs) -> str: """List/search Customers with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -491,7 +491,7 @@ def handle_get_customers(**kwargs) -> str: return _error(str(e)) -def handle_get_customer(**kwargs) -> str: +def _handle_get_customer(**kwargs) -> str: """Get a single Customer by name/ID with linked addresses and contacts.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -546,7 +546,7 @@ def handle_get_customer(**kwargs) -> str: # GL Entry (read-only) # --------------------------------------------------------------------------- -def handle_get_account_ledger(**kwargs) -> str: +def _handle_get_account_ledger(**kwargs) -> str: """Query GL Entries for an account with running balance. GL Entry is read-only.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -609,7 +609,7 @@ def handle_get_account_ledger(**kwargs) -> str: # Journal Entry # --------------------------------------------------------------------------- -def handle_create_journal_entry(**kwargs) -> str: +def _handle_create_journal_entry(**kwargs) -> str: """Create a draft Journal Entry with account lines.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -648,7 +648,7 @@ def handle_create_journal_entry(**kwargs) -> str: # Request for Quotation # --------------------------------------------------------------------------- -def handle_get_rfqs(**kwargs) -> str: +def _handle_get_rfqs(**kwargs) -> str: """List Request for Quotation documents with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -681,3 +681,28 @@ def handle_get_rfqs(**kwargs) -> str: except Exception as e: frappe.log_error(f"ERPNext Get RFQs Error: {e}", "ERPNext Tool") return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "list_sales_invoices": _handle_get_sales_invoices, + "get_sales_invoice": _handle_get_sales_invoice, + "create_sales_invoice": _handle_create_sales_invoice, + "list_purchase_invoices": _handle_get_purchase_invoices, + "get_purchase_invoice": _handle_get_purchase_invoice, + "list_payments": _handle_get_payments, + "create_payment": _handle_create_payment, + "list_customers": _handle_get_customers, + "get_customer": _handle_get_customer, + "list_quotations": _handle_get_quotations, + "create_quotation": _handle_create_quotation, + "list_rfqs": _handle_get_rfqs, + "get_ledger": _handle_get_account_ledger, + "create_journal_entry": _handle_create_journal_entry, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return handler(**kwargs) diff --git a/huf/ai/tools/erpnext_crm.py b/huf/ai/tools/erpnext_crm.py index dd455da3..ea9c2e25 100644 --- a/huf/ai/tools/erpnext_crm.py +++ b/huf/ai/tools/erpnext_crm.py @@ -23,7 +23,7 @@ def _error(msg): # Lead # --------------------------------------------------------------------------- -def handle_get_leads(**kwargs) -> str: +def _handle_get_leads(**kwargs) -> str: """List ERPNext leads with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -75,7 +75,7 @@ def handle_get_leads(**kwargs) -> str: return _error(str(e)) -def handle_get_lead(**kwargs) -> str: +def _handle_get_lead(**kwargs) -> str: """Get a single ERPNext Lead by name/ID with all fields.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -94,7 +94,7 @@ def handle_get_lead(**kwargs) -> str: return _error(str(e)) -def handle_create_lead(**kwargs) -> str: +def _handle_create_lead(**kwargs) -> str: """Create a new ERPNext Lead.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -127,7 +127,7 @@ def handle_create_lead(**kwargs) -> str: return _error(str(e)) -def handle_update_lead(**kwargs) -> str: +def _handle_update_lead(**kwargs) -> str: """Update fields on an existing ERPNext Lead.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -171,7 +171,7 @@ def handle_update_lead(**kwargs) -> str: # Opportunity # --------------------------------------------------------------------------- -def handle_get_opportunities(**kwargs) -> str: +def _handle_get_opportunities(**kwargs) -> str: """List ERPNext Opportunities with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -218,7 +218,7 @@ def handle_get_opportunities(**kwargs) -> str: return _error(str(e)) -def handle_create_opportunity(**kwargs) -> str: +def _handle_create_opportunity(**kwargs) -> str: """Create a new ERPNext Opportunity.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -249,7 +249,7 @@ def handle_create_opportunity(**kwargs) -> str: return _error(str(e)) -def handle_update_opportunity(**kwargs) -> str: +def _handle_update_opportunity(**kwargs) -> str: """Update fields on an existing ERPNext Opportunity.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -282,3 +282,21 @@ def handle_update_opportunity(**kwargs) -> str: except Exception as e: frappe.log_error(f"ERPNext CRM Update Opportunity Error: {e}", "ERPNext CRM Tool") return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "list_leads": _handle_get_leads, + "get_lead": _handle_get_lead, + "create_lead": _handle_create_lead, + "update_lead": _handle_update_lead, + "list_opportunities": _handle_get_opportunities, + "create_opportunity": _handle_create_opportunity, + "update_opportunity": _handle_update_opportunity, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return handler(**kwargs) diff --git a/huf/ai/tools/erpnext_inventory.py b/huf/ai/tools/erpnext_inventory.py index 0610745a..42226bc9 100644 --- a/huf/ai/tools/erpnext_inventory.py +++ b/huf/ai/tools/erpnext_inventory.py @@ -26,7 +26,7 @@ def _docstatus_label(ds): # Items # --------------------------------------------------------------------------- -def handle_get_items(**kwargs) -> str: +def _handle_get_items(**kwargs) -> str: """List ERPNext items with optional search and filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -78,7 +78,7 @@ def handle_get_items(**kwargs) -> str: return _error(str(e)) -def handle_get_item(**kwargs) -> str: +def _handle_get_item(**kwargs) -> str: """Get a single ERPNext item by item_code with item_defaults child table.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -98,7 +98,7 @@ def handle_get_item(**kwargs) -> str: return _error(str(e)) -def handle_get_item_prices(**kwargs) -> str: +def _handle_get_item_prices(**kwargs) -> str: """List ERPNext item prices for an item with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -150,7 +150,7 @@ def handle_get_item_prices(**kwargs) -> str: # BOM # --------------------------------------------------------------------------- -def handle_get_boms(**kwargs) -> str: +def _handle_get_boms(**kwargs) -> str: """List ERPNext BOMs with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -199,7 +199,7 @@ def handle_get_boms(**kwargs) -> str: return _error(str(e)) -def handle_get_bom(**kwargs) -> str: +def _handle_get_bom(**kwargs) -> str: """Get a single ERPNext BOM by name with items and operations child tables.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -219,7 +219,7 @@ def handle_get_bom(**kwargs) -> str: return _error(str(e)) -def handle_create_bom(**kwargs) -> str: +def _handle_create_bom(**kwargs) -> str: """Create a draft ERPNext BOM. Provide item, quantity, and line items.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -262,7 +262,7 @@ def handle_create_bom(**kwargs) -> str: # Stock & Inventory # --------------------------------------------------------------------------- -def handle_get_stock_balance(**kwargs) -> str: +def _handle_get_stock_balance(**kwargs) -> str: """Get current stock balance per item and warehouse from Stock Ledger Entry.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -319,7 +319,7 @@ def handle_get_stock_balance(**kwargs) -> str: return _error(str(e)) -def handle_get_stock_movements(**kwargs) -> str: +def _handle_get_stock_movements(**kwargs) -> str: """List ERPNext stock ledger entries with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -372,7 +372,7 @@ def handle_get_stock_movements(**kwargs) -> str: return _error(str(e)) -def handle_get_stock_entries(**kwargs) -> str: +def _handle_get_stock_entries(**kwargs) -> str: """List ERPNext stock entry documents with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -419,7 +419,7 @@ def handle_get_stock_entries(**kwargs) -> str: return _error(str(e)) -def handle_get_warehouses(**kwargs) -> str: +def _handle_get_warehouses(**kwargs) -> str: """List ERPNext warehouses with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -458,7 +458,7 @@ def handle_get_warehouses(**kwargs) -> str: return _error(str(e)) -def handle_get_delivery_notes(**kwargs) -> str: +def _handle_get_delivery_notes(**kwargs) -> str: """List ERPNext delivery notes with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -506,7 +506,7 @@ def handle_get_delivery_notes(**kwargs) -> str: return _error(str(e)) -def handle_get_purchase_receipts(**kwargs) -> str: +def _handle_get_purchase_receipts(**kwargs) -> str: """List ERPNext purchase receipts with optional filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") @@ -552,3 +552,26 @@ def handle_get_purchase_receipts(**kwargs) -> str: except Exception as e: frappe.log_error(f"ERPNext Get Purchase Receipts Error: {e}", "ERPNext Tool") return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "list_items": _handle_get_items, + "get_item": _handle_get_item, + "item_prices": _handle_get_item_prices, + "stock_balance": _handle_get_stock_balance, + "stock_movements": _handle_get_stock_movements, + "list_stock_entries": _handle_get_stock_entries, + "list_warehouses": _handle_get_warehouses, + "list_delivery_notes": _handle_get_delivery_notes, + "list_purchase_receipts": _handle_get_purchase_receipts, + "list_boms": _handle_get_boms, + "get_bom": _handle_get_bom, + "create_bom": _handle_create_bom, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return handler(**kwargs) diff --git a/huf/ai/tools/erpnext_reports.py b/huf/ai/tools/erpnext_reports.py index cb76f1d0..d84c2747 100644 --- a/huf/ai/tools/erpnext_reports.py +++ b/huf/ai/tools/erpnext_reports.py @@ -18,400 +18,108 @@ def _error(msg): return json.dumps({"success": False, "error": msg}) -def _run_report(report_name, filters): - """Helper to run an ERPNext query report and return a serializable result.""" - from frappe.desk.query_report import run as run_report - - # Remove empty string values to avoid report errors - cleaned = {k: v for k, v in filters.items() if v != ""} - - result = run_report( - report_name=report_name, - filters=cleaned, - ignore_prepared_report=True, - ) - - # Serialize columns and rows safely - columns = [] - for col in result.get("columns", []): - if isinstance(col, dict): - columns.append({ - "label": col.get("label", ""), - "fieldname": col.get("fieldname", ""), - "fieldtype": col.get("fieldtype", ""), - "width": col.get("width", 0), - }) - elif isinstance(col, str): - columns.append({"label": col, "fieldname": col, "fieldtype": "Data"}) - else: - columns.append({"label": str(col), "fieldname": str(col), "fieldtype": "Data"}) - - rows = result.get("result", []) - serializable_rows = [] - for row in rows: - if isinstance(row, dict): - serializable_rows.append({k: (v if v is not None else "") for k, v in row.items()}) - elif hasattr(row, "__dict__"): - serializable_rows.append({k: (v if v is not None else "") for k, v in row.__dict__.items()}) - else: - serializable_rows.append({"value": str(row)}) - - return {"success": True, "results": serializable_rows, "columns": columns} - - -# --------------------------------------------------------------------------- -# Financial Statements -# --------------------------------------------------------------------------- - -def handle_balance_sheet(**kwargs) -> str: - """Run ERPNext Balance Sheet report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - company = kwargs.get("company") - if not company: - return _error("company is required") - - filters = { - "company": company, - "fiscal_year": kwargs.get("fiscal_year", ""), - "from_fiscal_year": kwargs.get("from_fiscal_year", ""), - "to_fiscal_year": kwargs.get("to_fiscal_year", ""), - "periodicity": kwargs.get("periodicity", ""), - "accumulated_values": int(kwargs.get("accumulated_values", 1)), - } - - return json.dumps(_run_report("Balance Sheet", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Balance Sheet Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -def handle_profit_and_loss(**kwargs) -> str: - """Run ERPNext Profit and Loss Statement report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - company = kwargs.get("company") - if not company: - return _error("company is required") - - filters = { - "company": company, - "fiscal_year": kwargs.get("fiscal_year", ""), - "periodicity": kwargs.get("periodicity", ""), - "from_date": kwargs.get("from_date", ""), - "to_date": kwargs.get("to_date", ""), - } - - return json.dumps(_run_report("Profit and Loss Statement", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Profit and Loss Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -def handle_trial_balance(**kwargs) -> str: - """Run ERPNext Trial Balance report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - company = kwargs.get("company") - if not company: - return _error("company is required") - - filters = { - "company": company, - "from_date": kwargs.get("from_date", ""), - "to_date": kwargs.get("to_date", ""), - "show_zero_values": int(kwargs.get("show_zero_values", 0)), - } - - return json.dumps(_run_report("Trial Balance", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Trial Balance Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -# --------------------------------------------------------------------------- -# Accounts Reports -# --------------------------------------------------------------------------- - -def handle_general_ledger(**kwargs) -> str: - """Run ERPNext General Ledger report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - company = kwargs.get("company") - if not company: - return _error("company is required") - - filters = { - "company": company, - "from_date": kwargs.get("from_date", ""), - "to_date": kwargs.get("to_date", ""), - "account": kwargs.get("account", ""), - "party_type": kwargs.get("party_type", ""), - "party": kwargs.get("party", ""), - "voucher_no": kwargs.get("voucher_no", ""), - "group_by": kwargs.get("group_by", ""), - } - - result = _run_report("General Ledger", filters) - rows = result.get("results", []) - if len(rows) > int(kwargs.get("limit", 500)): - rows = rows[: int(kwargs.get("limit", 500))] - result["results"] = rows - result["truncated"] = True - - return json.dumps(result) - except Exception as e: - frappe.log_error(f"ERPNext General Ledger Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -def handle_accounts_receivable(**kwargs) -> str: - """Run ERPNext Accounts Receivable report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - company = kwargs.get("company") - if not company: - return _error("company is required") - - filters = { - "company": company, - "report_date": kwargs.get("report_date", ""), - "ageing_based_on": kwargs.get("ageing_based_on", "Due Date"), - "range1": int(kwargs.get("range1", 30)), - "range2": int(kwargs.get("range2", 60)), - "range3": int(kwargs.get("range3", 90)), - "customer": kwargs.get("customer", ""), - "payment_terms_template": kwargs.get("payment_terms_template", ""), - } - - return json.dumps(_run_report("Accounts Receivable", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Accounts Receivable Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -def handle_accounts_payable(**kwargs) -> str: - """Run ERPNext Accounts Payable report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - company = kwargs.get("company") - if not company: - return _error("company is required") - - filters = { - "company": company, - "report_date": kwargs.get("report_date", ""), - "ageing_based_on": kwargs.get("ageing_based_on", "Due Date"), - "range1": int(kwargs.get("range1", 30)), - "range2": int(kwargs.get("range2", 60)), - "range3": int(kwargs.get("range3", 90)), - "supplier": kwargs.get("supplier", ""), - } - - return json.dumps(_run_report("Accounts Payable", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Accounts Payable Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -def handle_bank_reconciliation(**kwargs) -> str: - """Run ERPNext Bank Reconciliation Statement report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - company = kwargs.get("company") - account = kwargs.get("account") - if not company: - return _error("company is required") - if not account: - return _error("account is required") - - filters = { - "company": company, - "account": account, - "from_date": kwargs.get("from_date", ""), - "to_date": kwargs.get("to_date", ""), - "include_pos_transactions": int(kwargs.get("include_pos_transactions", 0)), - } - - return json.dumps(_run_report("Bank Reconciliation Statement", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Bank Reconciliation Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -# --------------------------------------------------------------------------- -# Sales Reports -# --------------------------------------------------------------------------- - -def handle_sales_register(**kwargs) -> str: - """Run ERPNext Sales Register report.""" +# Catalogue of available reports per module (used by list_reports) +REPORT_CATALOGUE = { + "Accounts": [ + "Balance Sheet", "Profit and Loss Statement", "Trial Balance", + "Cash Flow", "General Ledger", "Accounts Receivable", + "Accounts Receivable Summary", "Accounts Payable", "Accounts Payable Summary", + "Customer Ledger Summary", "Supplier Ledger Summary", + "Sales Register", "Purchase Register", + "Item-wise Sales Register", "Item-wise Purchase Register", + "Gross Profit", "Gross and Net Profit Report", "Profitability Analysis", + "Bank Reconciliation Statement", "Bank Clearance Summary", + "Payment Ledger", "Budget Variance Report", + "Trial Balance for Party", "Voucher-wise Balance", + ], + "Selling": [ + "Sales Analytics", "Sales Order Analysis", "Sales Order Trends", + "Quotation Trends", "Lost Quotations", "Inactive Customers", + "Customer Acquisition and Loyalty", "Customer Credit Balance", + "Sales Person Commission Summary", "Territory-wise Sales", + "Sales Payment Summary", + ], + "Buying": [ + "Purchase Analytics", "Purchase Order Analysis", "Purchase Order Trends", + "Supplier Quotation Comparison", "Procurement Tracker", + "Requested Items to Order and Receive", + ], + "Stock": [ + "Stock Balance", "Stock Ledger", "Stock Projected Qty", + "Stock Ageing", "Item Shortage Report", "Total Stock Summary", + "Warehouse Wise Stock Balance", "Stock Analytics", + "Available Batch Report", "Batch-Wise Balance History", + "BOM Stock Report", "Item Price Stock", + ], + "Manufacturing": [ + "BOM Explorer", "BOM Stock Report", "BOM Variance Report", + "Work Order Summary", "Production Analytics", + "Job Card Summary", "Production Planning Report", + ], + "CRM": [ + "Sales Pipeline Analytics", "Lead Details", "Lead Owner Efficiency", + "Lost Opportunity", "Opportunity Summary by Sales Stage", + "First Response Time for Opportunity", "Campaign Efficiency", + ], + "Helpdesk": [ + "Ticket Analytics", "Ticket Summary", "First Response Time for Tickets", + "Support Hour Distribution", + ], + "Projects": [ + "Project Summary", "Timesheet Billing Summary", "Daily Timesheet Summary", + ], + "HR": [ + "Salary Register", "Monthly Attendance Sheet", + "Employee Leave Balance", "Employee Analytics", + ], +} + + +def handle_run_report(**kwargs) -> str: + """Run any ERPNext/Frappe script report by name with filters.""" if not _erpnext_installed(): return _error("ERPNext is not installed.") - + report_name = kwargs.get("report_name") try: - from_date = kwargs.get("from_date") - to_date = kwargs.get("to_date") - if not from_date: - return _error("from_date is required") - if not to_date: - return _error("to_date is required") - - filters = { - "company": kwargs.get("company", ""), - "from_date": from_date, - "to_date": to_date, - "customer": kwargs.get("customer", ""), - "item_code": kwargs.get("item_code", ""), - } - - return json.dumps(_run_report("Sales Register", filters)) + if not report_name: + return _error("report_name is required. Call erpnext_list_reports to see available reports.") + + # Parse filters - accept dict or JSON string + filters = kwargs.get("filters", {}) + if isinstance(filters, str): + try: + filters = json.loads(filters) + except Exception: + return _error("filters must be a JSON object or dict") + + # Fill company default if not provided + if not filters.get("company"): + default_company = frappe.defaults.get_user_default("Company") or frappe.db.get_single_value("Global Defaults", "default_company") + if default_company: + filters["company"] = default_company + + from frappe.desk.query_report import run as run_report + result = run_report(report_name=report_name, filters=filters, ignore_prepared_report=True) + return json.dumps({ + "success": True, + "report_name": report_name, + "columns": result.get("columns", []), + "results": result.get("result", []), + "count": len(result.get("result", [])), + }) except Exception as e: - frappe.log_error(f"ERPNext Sales Register Error: {e}", "ERPNext Report Tool") + frappe.log_error(f"ERPNext Report Error [{report_name}]: {e}", "ERPNext Reports Tool") return _error(str(e)) -def handle_sales_order_analysis(**kwargs) -> str: - """Run ERPNext Sales Order Analysis report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - filters = { - "company": kwargs.get("company", ""), - "from_date": kwargs.get("from_date", ""), - "to_date": kwargs.get("to_date", ""), - "customer": kwargs.get("customer", ""), - "item_code": kwargs.get("item_code", ""), - "status": kwargs.get("status", ""), - } - - return json.dumps(_run_report("Sales Order Analysis", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Sales Order Analysis Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -def handle_customer_acquisition(**kwargs) -> str: - """Run ERPNext Customer Acquisition and Loyalty report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - filters = { - "company": kwargs.get("company", ""), - "from_date": kwargs.get("from_date", ""), - "to_date": kwargs.get("to_date", ""), - "customer_group": kwargs.get("customer_group", ""), - "territory": kwargs.get("territory", ""), - } - - return json.dumps(_run_report("Customer Acquisition and Loyalty", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Customer Acquisition Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -# --------------------------------------------------------------------------- -# Stock / Inventory Reports -# --------------------------------------------------------------------------- - -def handle_stock_balance_report(**kwargs) -> str: - """Run ERPNext Stock Balance report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - filters = { - "company": kwargs.get("company", ""), - "from_date": kwargs.get("from_date", ""), - "to_date": kwargs.get("to_date", ""), - "item_code": kwargs.get("item_code", ""), - "warehouse": kwargs.get("warehouse", ""), - "item_group": kwargs.get("item_group", ""), - } - - return json.dumps(_run_report("Stock Balance", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Stock Balance Report Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -def handle_stock_ledger_report(**kwargs) -> str: - """Run ERPNext Stock Ledger report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - from_date = kwargs.get("from_date") - to_date = kwargs.get("to_date") - if not from_date: - return _error("from_date is required") - if not to_date: - return _error("to_date is required") - - filters = { - "company": kwargs.get("company", ""), - "from_date": from_date, - "to_date": to_date, - "item_code": kwargs.get("item_code", ""), - "warehouse": kwargs.get("warehouse", ""), - "voucher_no": kwargs.get("voucher_no", ""), - } - - return json.dumps(_run_report("Stock Ledger", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Stock Ledger Report Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -def handle_item_wise_sales(**kwargs) -> str: - """Run ERPNext Item-wise Sales Register report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - filters = { - "company": kwargs.get("company", ""), - "from_date": kwargs.get("from_date", ""), - "to_date": kwargs.get("to_date", ""), - "item_code": kwargs.get("item_code", ""), - "customer": kwargs.get("customer", ""), - } - - return json.dumps(_run_report("Item-wise Sales Register", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Item-wise Sales Error: {e}", "ERPNext Report Tool") - return _error(str(e)) - - -def handle_gross_profit(**kwargs) -> str: - """Run ERPNext Gross Profit report.""" - if not _erpnext_installed(): - return _error("ERPNext is not installed.") - - try: - filters = { - "company": kwargs.get("company", ""), - "from_date": kwargs.get("from_date", ""), - "to_date": kwargs.get("to_date", ""), - "group_by": kwargs.get("group_by", ""), - } - - return json.dumps(_run_report("Gross Profit", filters)) - except Exception as e: - frappe.log_error(f"ERPNext Gross Profit Error: {e}", "ERPNext Report Tool") - return _error(str(e)) +def handle_list_reports(**kwargs) -> str: + """List available reports, optionally filtered by module.""" + module = kwargs.get("module", "").strip() + if module: + # case-insensitive match + matched = {k: v for k, v in REPORT_CATALOGUE.items() if k.lower() == module.lower()} + if not matched: + available = list(REPORT_CATALOGUE.keys()) + return json.dumps({"success": False, "error": f"Module '{module}' not found. Available: {available}"}) + return json.dumps({"success": True, "results": matched}) + return json.dumps({"success": True, "results": REPORT_CATALOGUE}) diff --git a/huf/ai/tools/github.py b/huf/ai/tools/github.py index 7a0b6154..8321f135 100644 --- a/huf/ai/tools/github.py +++ b/huf/ai/tools/github.py @@ -44,7 +44,7 @@ def _make_github_request(method: str, endpoint: str, json_data=None, params=None return response.json() if response.text else {} -def handle_list_repos(**kwargs) -> str: +def _handle_list_repos(**kwargs) -> str: """List GitHub repositories for the authenticated user.""" service_name = "github" try: @@ -76,7 +76,7 @@ def handle_list_repos(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_get_repo(**kwargs) -> str: +def _handle_get_repo(**kwargs) -> str: """Get details of a GitHub repository.""" service_name = "github" try: @@ -113,7 +113,7 @@ def handle_get_repo(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_create_issue(**kwargs) -> str: +def _handle_create_issue(**kwargs) -> str: """Create a GitHub issue.""" service_name = "github" try: @@ -149,7 +149,7 @@ def handle_create_issue(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_create_pull_request(**kwargs) -> str: +def _handle_create_pull_request(**kwargs) -> str: """Create a GitHub pull request.""" service_name = "github" try: @@ -192,7 +192,7 @@ def handle_create_pull_request(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_get_file_content(**kwargs) -> str: +def _handle_get_file_content(**kwargs) -> str: """Get file content from a GitHub repository.""" service_name = "github" try: @@ -227,7 +227,7 @@ def handle_get_file_content(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_search_code(**kwargs) -> str: +def _handle_search_code(**kwargs) -> str: """Search code across GitHub.""" service_name = "github" try: @@ -258,3 +258,20 @@ def handle_search_code(**kwargs) -> str: frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) return json.dumps({"success": False, "error": str(e)}) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "list_repos": _handle_list_repos, + "get_repo": _handle_get_repo, + "create_issue": _handle_create_issue, + "create_pr": _handle_create_pull_request, + "get_file": _handle_get_file_content, + "search_code": _handle_search_code, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return handler(**kwargs) diff --git a/huf/ai/tools/helpdesk.py b/huf/ai/tools/helpdesk.py index 4e7dd525..6c0235ba 100644 --- a/huf/ai/tools/helpdesk.py +++ b/huf/ai/tools/helpdesk.py @@ -22,7 +22,7 @@ def _error(msg): # Tickets # --------------------------------------------------------------------------- -def handle_get_tickets(**kwargs) -> str: +def _handle_get_tickets(**kwargs) -> str: """List tickets with filters (status, priority, assigned_to, team, search).""" if not _helpdesk_installed(): return _error("Frappe Helpdesk app is not installed.") @@ -84,7 +84,7 @@ def handle_get_tickets(**kwargs) -> str: return _error(str(e)) -def handle_get_ticket(**kwargs) -> str: +def _handle_get_ticket(**kwargs) -> str: """Get single ticket details with comments.""" if not _helpdesk_installed(): return _error("Frappe Helpdesk app is not installed.") @@ -113,7 +113,7 @@ def handle_get_ticket(**kwargs) -> str: return _error(str(e)) -def handle_create_ticket(**kwargs) -> str: +def _handle_create_ticket(**kwargs) -> str: """Create a support ticket.""" if not _helpdesk_installed(): return _error("Frappe Helpdesk app is not installed.") @@ -140,7 +140,7 @@ def handle_create_ticket(**kwargs) -> str: return _error(str(e)) -def handle_update_ticket(**kwargs) -> str: +def _handle_update_ticket(**kwargs) -> str: """Update ticket (status, priority, assigned_to, team).""" if not _helpdesk_installed(): return _error("Frappe Helpdesk app is not installed.") @@ -183,7 +183,7 @@ def handle_update_ticket(**kwargs) -> str: return _error(str(e)) -def handle_add_comment(**kwargs) -> str: +def _handle_add_comment(**kwargs) -> str: """Add a comment/reply to a ticket.""" if not _helpdesk_installed(): return _error("Frappe Helpdesk app is not installed.") @@ -212,7 +212,7 @@ def handle_add_comment(**kwargs) -> str: # Agents & Teams # --------------------------------------------------------------------------- -def handle_get_agents(**kwargs) -> str: +def _handle_get_agents(**kwargs) -> str: """List helpdesk agents.""" if not _helpdesk_installed(): return _error("Frappe Helpdesk app is not installed.") @@ -248,7 +248,7 @@ def handle_get_agents(**kwargs) -> str: return _error(str(e)) -def handle_get_teams(**kwargs) -> str: +def _handle_get_teams(**kwargs) -> str: """List helpdesk teams.""" if not _helpdesk_installed(): return _error("Frappe Helpdesk app is not installed.") @@ -277,7 +277,7 @@ def handle_get_teams(**kwargs) -> str: return _error(str(e)) -def handle_assign_ticket(**kwargs) -> str: +def _handle_assign_ticket(**kwargs) -> str: """Assign ticket to an agent.""" if not _helpdesk_installed(): return _error("Frappe Helpdesk app is not installed.") @@ -299,3 +299,22 @@ def handle_assign_ticket(**kwargs) -> str: except Exception as e: frappe.log_error(f"Helpdesk Assign Ticket Error: {e}", "Helpdesk Tool") return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "list_tickets": _handle_get_tickets, + "get_ticket": _handle_get_ticket, + "create_ticket": _handle_create_ticket, + "update_ticket": _handle_update_ticket, + "add_comment": _handle_add_comment, + "list_agents": _handle_get_agents, + "list_teams": _handle_get_teams, + "assign_ticket": _handle_assign_ticket, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return handler(**kwargs) diff --git a/huf/ai/tools/raven.py b/huf/ai/tools/raven.py index e16fdc60..0de816f9 100644 --- a/huf/ai/tools/raven.py +++ b/huf/ai/tools/raven.py @@ -32,7 +32,7 @@ def _resolve_channel_id(channel_id=None, channel_name=None): # Messages # --------------------------------------------------------------------------- -def handle_send_message(**kwargs) -> str: +def _handle_send_message(**kwargs) -> str: """Send a message to a Raven channel.""" if not _raven_installed(): return _error("Frappe Raven app is not installed.") @@ -59,7 +59,7 @@ def handle_send_message(**kwargs) -> str: return _error(str(e)) -def handle_get_messages(**kwargs) -> str: +def _handle_get_messages(**kwargs) -> str: """Get recent messages from a channel.""" if not _raven_installed(): return _error("Frappe Raven app is not installed.") @@ -112,7 +112,7 @@ def handle_get_messages(**kwargs) -> str: # Channels # --------------------------------------------------------------------------- -def handle_list_channels(**kwargs) -> str: +def _handle_list_channels(**kwargs) -> str: """List all channels (optionally filter by type: public/private/open).""" if not _raven_installed(): return _error("Frappe Raven app is not installed.") @@ -153,7 +153,7 @@ def handle_list_channels(**kwargs) -> str: return _error(str(e)) -def handle_get_channel_members(**kwargs) -> str: +def _handle_get_channel_members(**kwargs) -> str: """Get members of a channel.""" if not _raven_installed(): return _error("Frappe Raven app is not installed.") @@ -183,7 +183,7 @@ def handle_get_channel_members(**kwargs) -> str: return _error(str(e)) -def handle_create_channel(**kwargs) -> str: +def _handle_create_channel(**kwargs) -> str: """Create a new channel.""" if not _raven_installed(): return _error("Frappe Raven app is not installed.") @@ -239,7 +239,7 @@ def handle_create_channel(**kwargs) -> str: # Search # --------------------------------------------------------------------------- -def handle_search_messages(**kwargs) -> str: +def _handle_search_messages(**kwargs) -> str: """Search messages across channels or within a specific channel.""" if not _raven_installed(): return _error("Frappe Raven app is not installed.") @@ -283,3 +283,20 @@ def handle_search_messages(**kwargs) -> str: except Exception as e: frappe.log_error(f"Raven Search Messages Error: {e}", "Raven Tool") return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "send_message": _handle_send_message, + "get_messages": _handle_get_messages, + "list_channels": _handle_list_channels, + "get_members": _handle_get_channel_members, + "create_channel": _handle_create_channel, + "search_messages": _handle_search_messages, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return handler(**kwargs) diff --git a/huf/ai/tools/slack.py b/huf/ai/tools/slack.py index 3c1f87d9..7d548885 100644 --- a/huf/ai/tools/slack.py +++ b/huf/ai/tools/slack.py @@ -21,7 +21,7 @@ def _get_slack_headers(): } -def handle_send_message(**kwargs) -> str: +def _handle_send_message(**kwargs) -> str: """Send a message to a Slack channel.""" service_name = "slack" try: @@ -55,7 +55,7 @@ def handle_send_message(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_send_message_thread(**kwargs) -> str: +def _handle_send_message_thread(**kwargs) -> str: """Reply to a message thread in a Slack channel.""" service_name = "slack" try: @@ -90,7 +90,7 @@ def handle_send_message_thread(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_list_channels(**kwargs) -> str: +def _handle_list_channels(**kwargs) -> str: """List all channels in the Slack workspace.""" service_name = "slack" try: @@ -125,7 +125,7 @@ def handle_list_channels(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_get_channel_history(**kwargs) -> str: +def _handle_get_channel_history(**kwargs) -> str: """Get message history of a Slack channel.""" service_name = "slack" try: @@ -165,7 +165,7 @@ def handle_get_channel_history(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_search_messages(**kwargs) -> str: +def _handle_search_messages(**kwargs) -> str: """Search messages across the Slack workspace.""" service_name = "slack" try: @@ -206,7 +206,7 @@ def handle_search_messages(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def handle_list_users(**kwargs) -> str: +def _handle_list_users(**kwargs) -> str: """List all users in the Slack workspace.""" service_name = "slack" try: @@ -241,3 +241,20 @@ def handle_list_users(**kwargs) -> str: frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) return json.dumps({"success": False, "error": str(e)}) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "send_message": _handle_send_message, + "reply_thread": _handle_send_message_thread, + "list_channels": _handle_list_channels, + "get_history": _handle_get_channel_history, + "search_messages": _handle_search_messages, + "list_users": _handle_list_users, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return handler(**kwargs) diff --git a/huf/ai/tools/telegram.py b/huf/ai/tools/telegram.py index 9c0ebdb5..a64f5dd4 100644 --- a/huf/ai/tools/telegram.py +++ b/huf/ai/tools/telegram.py @@ -9,7 +9,7 @@ from huf.ai.tools.credentials import require_credential, get_credential, update_last_error -def handle_send_message(**kwargs) -> str: +def _handle_send_message(**kwargs) -> str: """Send a message via Telegram bot.""" service_name = "telegram" try: @@ -44,3 +44,15 @@ def handle_send_message(**kwargs) -> str: frappe.log_error(error_msg, "Telegram Tool") update_last_error(service_name, error_msg) return json.dumps({"success": False, "error": str(e)}) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "send_message": _handle_send_message, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return handler(**kwargs) From 91d83c700d3df9a15fecbf7d2a3b4fdb2926b870 Mon Sep 17 00:00:00 2001 From: esafwan Date: Fri, 29 May 2026 02:54:21 +0400 Subject: [PATCH 06/12] fix: restore develop tools untouched, scope consolidation to new tools only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack, Discord, Telegram, GitHub and recipient tools were already merged to develop (PR #273) and should not be refactored in this branch. Reverted to develop-identical state: huf/ai/tools/slack.py huf/ai/tools/discord.py huf/ai/tools/telegram.py huf/ai/tools/github.py huf/ai/tools/recipient.py Registry now has 26 tools: 18 from develop (unchanged) — individual slack_*/discord_*/github_* tools 8 new (consolidated) — frappe_crm, helpdesk, raven, erpnext, erpnext_crm, erpnext_inventory, erpnext_run_report, erpnext_list_reports --- huf/ai/tools/_registry.py | 304 +++++++++++++++++++++++++++----------- huf/ai/tools/discord.py | 23 +-- huf/ai/tools/github.py | 29 +--- huf/ai/tools/slack.py | 29 +--- huf/ai/tools/telegram.py | 14 +- 5 files changed, 234 insertions(+), 165 deletions(-) diff --git a/huf/ai/tools/_registry.py b/huf/ai/tools/_registry.py index 7d57c1b4..cf0eed6d 100644 --- a/huf/ai/tools/_registry.py +++ b/huf/ai/tools/_registry.py @@ -11,92 +11,218 @@ def _p(name, type="string", required=False, description=""): - return { - "label": name.replace("_", " ").title(), - "fieldname": name, - "type": type, - "required": int(required), - "description": description, - } + return { + "label": name.replace("_", " ").title(), + "fieldname": name, + "type": type, + "required": int(required), + "description": description, + } def _action(choices): - return _p("action", required=True, description=f"Action to perform. One of: {choices}") + return _p("action", required=True, description=f"Action to perform. One of: {choices}") # --------------------------------------------------------------------------- -# Communication Tools +# Communication & Developer Tools +# Shipped in develop (PR #273). Not refactored — kept exactly as-is. # --------------------------------------------------------------------------- -SLACK_TOOLS = [{ - "tool_name": "slack", - "description": "Interact with Slack workspace. Actions: send_message (channel, text), reply_thread (channel, text, thread_ts), list_channels, get_history (channel, limit), search_messages (query, limit), list_users (limit).", - "function_path": "huf.ai.tools.slack.handle_action", - "category": "Communication Tools", - "parameters": [ - _action("send_message|reply_thread|list_channels|get_history|search_messages|list_users"), - _p("channel", description="Channel ID or name"), - _p("text", description="Message text"), - _p("thread_ts", description="Parent message timestamp (for reply_thread)"), - _p("query", description="Search query (for search_messages)"), - _p("limit", type="integer", description="Max results"), - ], -}] +RECIPIENT_TOOLS = [ + { + "tool_name": "get_integration_recipient", + "description": ( + "Look up a named recipient's service-specific ID from Integration Settings. " + "Use this before sending a message to resolve a human name (e.g. 'John Doe', 'Sales Team') " + "to the correct Telegram Chat ID, Slack User/Channel ID, Discord Channel ID, etc. " + "Call this tool first, then pass the returned recipient_id to the relevant send tool." + ), + "function_path": "huf.ai.tools.recipient.handle_get_recipient", + "category": "Communication Tools", + "parameters": [ + _p("service", required=True, description="The service name, e.g. 'telegram', 'slack', 'discord'"), + _p("recipient_name", required=True, description="Human-friendly recipient name as stored in Integration Settings, e.g. 'John Doe'"), + ], + }, +] -DISCORD_TOOLS = [{ - "tool_name": "discord", - "description": "Interact with Discord. Actions: send_message (channel_id, message), get_messages (channel_id, limit), list_channels (guild_id), delete_message (channel_id, message_id).", - "function_path": "huf.ai.tools.discord.handle_action", - "category": "Communication Tools", - "parameters": [ - _action("send_message|get_messages|list_channels|delete_message"), - _p("channel_id", description="Discord channel ID"), - _p("guild_id", description="Discord server (guild) ID"), - _p("message", description="Message text"), - _p("message_id", description="Message ID to delete"), - _p("limit", type="integer", description="Max messages"), - ], -}] +SLACK_TOOLS = [ + { + "tool_name": "slack_send_message", + "description": "Send a message to a Slack channel. Requires SLACK_TOKEN env var.", + "function_path": "huf.ai.tools.slack.handle_send_message", + "category": "Communication Tools", + "parameters": [ + _p("channel", required=True, description="Channel ID or name to send the message to"), + _p("text", required=True, description="Message text (supports Slack mrkdwn formatting)"), + ], + }, + { + "tool_name": "slack_send_thread_reply", + "description": "Reply to a message thread in a Slack channel. Requires SLACK_TOKEN env var.", + "function_path": "huf.ai.tools.slack.handle_send_message_thread", + "category": "Communication Tools", + "parameters": [ + _p("channel", required=True, description="Channel ID or name"), + _p("text", required=True, description="Reply text"), + _p("thread_ts", required=True, description="Timestamp of the parent message"), + ], + }, + { + "tool_name": "slack_list_channels", + "description": "List all channels in the Slack workspace. Requires SLACK_TOKEN env var.", + "function_path": "huf.ai.tools.slack.handle_list_channels", + "category": "Communication Tools", + "parameters": [], + }, + { + "tool_name": "slack_get_channel_history", + "description": "Get message history of a Slack channel. Requires SLACK_TOKEN env var.", + "function_path": "huf.ai.tools.slack.handle_get_channel_history", + "category": "Communication Tools", + "parameters": [ + _p("channel", required=True, description="Channel ID to fetch history from"), + _p("limit", type="integer", description="Max messages to fetch (default 100)"), + ], + }, + { + "tool_name": "slack_search_messages", + "description": "Search messages across the Slack workspace. Supports modifiers like from:@user, in:#channel. Requires SLACK_TOKEN env var.", + "function_path": "huf.ai.tools.slack.handle_search_messages", + "category": "Communication Tools", + "parameters": [ + _p("query", required=True, description="Search query"), + _p("limit", type="integer", description="Max results (default 20, max 100)"), + ], + }, + { + "tool_name": "slack_list_users", + "description": "List all users in the Slack workspace. Requires SLACK_TOKEN env var.", + "function_path": "huf.ai.tools.slack.handle_list_users", + "category": "Communication Tools", + "parameters": [ + _p("limit", type="integer", description="Max users to fetch (default 100)"), + ], + }, +] -TELEGRAM_TOOLS = [{ - "tool_name": "telegram", - "description": "Send messages via Telegram bot. Actions: send_message (chat_id, message).", - "function_path": "huf.ai.tools.telegram.handle_action", - "category": "Communication Tools", - "parameters": [ - _action("send_message"), - _p("chat_id", description="Telegram chat ID"), - _p("message", description="Message text"), - ], -}] +DISCORD_TOOLS = [ + { + "tool_name": "discord_send_message", + "description": "Send a message to a Discord channel. Requires DISCORD_BOT_TOKEN env var.", + "function_path": "huf.ai.tools.discord.handle_send_message", + "category": "Communication Tools", + "parameters": [ + _p("channel_id", required=True, description="Discord channel ID"), + _p("message", required=True, description="Message text to send"), + ], + }, + { + "tool_name": "discord_get_messages", + "description": "Get message history of a Discord channel. Requires DISCORD_BOT_TOKEN env var.", + "function_path": "huf.ai.tools.discord.handle_get_channel_messages", + "category": "Communication Tools", + "parameters": [ + _p("channel_id", required=True, description="Discord channel ID"), + _p("limit", type="integer", description="Max messages (default 50)"), + ], + }, + { + "tool_name": "discord_list_channels", + "description": "List all channels in a Discord server. Requires DISCORD_BOT_TOKEN env var.", + "function_path": "huf.ai.tools.discord.handle_list_channels", + "category": "Communication Tools", + "parameters": [ + _p("guild_id", required=True, description="Discord server (guild) ID"), + ], + }, + { + "tool_name": "discord_delete_message", + "description": "Delete a message from a Discord channel. Requires DISCORD_BOT_TOKEN env var.", + "function_path": "huf.ai.tools.discord.handle_delete_message", + "category": "Communication Tools", + "parameters": [ + _p("channel_id", required=True, description="Discord channel ID"), + _p("message_id", required=True, description="Message ID to delete"), + ], + }, +] -GITHUB_TOOLS = [{ - "tool_name": "github", - "description": "Interact with GitHub. Actions: list_repos, get_repo (repo_name), create_issue (repo_name, title, body), create_pr (repo_name, title, head, base, body), get_file (repo_name, path), search_code (query).", - "function_path": "huf.ai.tools.github.handle_action", - "category": "Developer Tools", - "parameters": [ - _action("list_repos|get_repo|create_issue|create_pr|get_file|search_code"), - _p("repo_name", description="Repository (owner/name)"), - _p("title", description="Issue or PR title"), - _p("body", description="Issue or PR body"), - _p("head", description="Head branch (for create_pr)"), - _p("base", description="Base branch (for create_pr)"), - _p("path", description="File path in repo (for get_file)"), - _p("query", description="Search query (for search_code)"), - ], -}] +TELEGRAM_TOOLS = [ + { + "tool_name": "telegram_send_message", + "description": "Send a message via Telegram bot. Requires TELEGRAM_TOKEN env var.", + "function_path": "huf.ai.tools.telegram.handle_send_message", + "category": "Communication Tools", + "parameters": [ + _p("chat_id", required=True, description="Telegram chat ID to send to"), + _p("message", required=True, description="Message text"), + ], + }, +] -RECIPIENT_TOOLS = [{ - "tool_name": "get_integration_recipient", - "description": "Look up a named recipient's service-specific ID (Slack user/channel, Telegram chat, Discord channel) from Integration Settings. Use before sending messages to resolve human names to IDs.", - "function_path": "huf.ai.tools.recipient.handle_get_recipient", - "category": "Communication Tools", - "parameters": [ - _p("service", required=True, description="Service name: telegram, slack, discord"), - _p("recipient_name", required=True, description="Human-friendly name as stored in Integration Settings"), - ], -}] +GITHUB_TOOLS = [ + { + "tool_name": "github_list_repos", + "description": "List GitHub repositories for the authenticated user. Requires GITHUB_ACCESS_TOKEN env var.", + "function_path": "huf.ai.tools.github.handle_list_repos", + "category": "Developer Tools", + "parameters": [], + }, + { + "tool_name": "github_get_repo", + "description": "Get details of a GitHub repository. Requires GITHUB_ACCESS_TOKEN env var.", + "function_path": "huf.ai.tools.github.handle_get_repo", + "category": "Developer Tools", + "parameters": [_p("repo_name", required=True, description="Repository (owner/name)")], + }, + { + "tool_name": "github_create_issue", + "description": "Create a GitHub issue. Requires GITHUB_ACCESS_TOKEN env var.", + "function_path": "huf.ai.tools.github.handle_create_issue", + "category": "Developer Tools", + "parameters": [ + _p("repo_name", required=True, description="Repository (owner/name)"), + _p("title", required=True, description="Issue title"), + _p("body", description="Issue body"), + ], + }, + { + "tool_name": "github_create_pr", + "description": "Create a GitHub pull request. Requires GITHUB_ACCESS_TOKEN env var.", + "function_path": "huf.ai.tools.github.handle_create_pull_request", + "category": "Developer Tools", + "parameters": [ + _p("repo_name", required=True, description="Repository (owner/name)"), + _p("title", required=True, description="PR title"), + _p("body", description="PR description"), + _p("head", required=True, description="Head branch"), + _p("base", required=True, description="Base branch"), + ], + }, + { + "tool_name": "github_get_file", + "description": "Get file content from a GitHub repository. Requires GITHUB_ACCESS_TOKEN env var.", + "function_path": "huf.ai.tools.github.handle_get_file_content", + "category": "Developer Tools", + "parameters": [ + _p("repo_name", required=True, description="Repository (owner/name)"), + _p("path", required=True, description="File path in repository"), + ], + }, + { + "tool_name": "github_search_code", + "description": "Search code across GitHub. Requires GITHUB_ACCESS_TOKEN env var.", + "function_path": "huf.ai.tools.github.handle_search_code", + "category": "Developer Tools", + "parameters": [_p("query", required=True, description="Code search query")], + }, +] + +# --------------------------------------------------------------------------- +# Frappe App Tools (added in this branch — consolidated action-based) +# --------------------------------------------------------------------------- CRM_TOOLS = [{ "tool_name": "frappe_crm", @@ -164,6 +290,10 @@ def _action(choices): ], }] +# --------------------------------------------------------------------------- +# ERPNext Tools (added in this branch — consolidated action-based) +# --------------------------------------------------------------------------- + ERPNEXT_TOOLS = [{ "tool_name": "erpnext", "description": "Manage ERPNext transactions and accounting. Actions: list_sales_invoices (customer, status, from_date, to_date, limit), get_sales_invoice (name), create_sales_invoice (customer required, items list [{item_code,qty,rate}], company, posting_date), list_purchase_invoices (supplier, status, from_date, to_date, limit), get_purchase_invoice (name), list_payments (party_type, party, payment_type, from_date, to_date, limit), create_payment (payment_type required [Receive/Pay], party_type required, party required, paid_amount required; mode_of_payment, invoice_name), list_customers (search, customer_group, limit), get_customer (name), list_quotations (party_name, status, from_date, limit), create_quotation (quotation_to required [Customer/Lead], party_name required, items list, transaction_date, valid_till), list_rfqs (status, from_date, limit), get_ledger (account required, from_date, to_date, party_type, party, limit), create_journal_entry (voucher_type, posting_date, company, user_remark, accounts list [{account, debit_in_account_currency, credit_in_account_currency}]).", @@ -278,20 +408,20 @@ def _action(choices): ] # --------------------------------------------------------------------------- -# Master list: every tool grouped for easy iteration +# Master list # --------------------------------------------------------------------------- ALL_INTEGRATION_TOOLS = ( - RECIPIENT_TOOLS - + SLACK_TOOLS - + DISCORD_TOOLS - + TELEGRAM_TOOLS - + GITHUB_TOOLS - + CRM_TOOLS - + HELPDESK_TOOLS - + RAVEN_TOOLS - + ERPNEXT_TOOLS - + ERPNEXT_CRM_TOOLS - + ERPNEXT_INVENTORY_TOOLS - + ERPNEXT_REPORT_TOOLS + RECIPIENT_TOOLS + + SLACK_TOOLS + + DISCORD_TOOLS + + TELEGRAM_TOOLS + + GITHUB_TOOLS + + CRM_TOOLS + + HELPDESK_TOOLS + + RAVEN_TOOLS + + ERPNEXT_TOOLS + + ERPNEXT_CRM_TOOLS + + ERPNEXT_INVENTORY_TOOLS + + ERPNEXT_REPORT_TOOLS ) diff --git a/huf/ai/tools/discord.py b/huf/ai/tools/discord.py index a94f22db..83878d69 100644 --- a/huf/ai/tools/discord.py +++ b/huf/ai/tools/discord.py @@ -21,7 +21,7 @@ def _get_discord_headers(): } -def _handle_send_message(**kwargs) -> str: +def handle_send_message(**kwargs) -> str: """Send a message to a Discord channel.""" service_name = "discord" try: @@ -52,7 +52,7 @@ def _handle_send_message(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_get_channel_messages(**kwargs) -> str: +def handle_get_channel_messages(**kwargs) -> str: """Get message history of a Discord channel.""" service_name = "discord" try: @@ -87,7 +87,7 @@ def _handle_get_channel_messages(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_list_channels(**kwargs) -> str: +def handle_list_channels(**kwargs) -> str: """List all channels in a Discord server (guild).""" service_name = "discord" try: @@ -120,7 +120,7 @@ def _handle_list_channels(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_delete_message(**kwargs) -> str: +def handle_delete_message(**kwargs) -> str: """Delete a message from a Discord channel.""" service_name = "discord" try: @@ -147,18 +147,3 @@ def _handle_delete_message(**kwargs) -> str: frappe.log_error(error_msg, "Discord Tool") update_last_error(service_name, error_msg) return json.dumps({"success": False, "error": str(e)}) - - -def handle_action(**kwargs) -> str: - action = kwargs.get("action", "").strip().lower() - dispatch = { - "send_message": _handle_send_message, - "get_messages": _handle_get_channel_messages, - "list_channels": _handle_list_channels, - "delete_message": _handle_delete_message, - } - handler = dispatch.get(action) - if not handler: - valid = ", ".join(sorted(dispatch.keys())) - return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) - return handler(**kwargs) diff --git a/huf/ai/tools/github.py b/huf/ai/tools/github.py index 8321f135..7a0b6154 100644 --- a/huf/ai/tools/github.py +++ b/huf/ai/tools/github.py @@ -44,7 +44,7 @@ def _make_github_request(method: str, endpoint: str, json_data=None, params=None return response.json() if response.text else {} -def _handle_list_repos(**kwargs) -> str: +def handle_list_repos(**kwargs) -> str: """List GitHub repositories for the authenticated user.""" service_name = "github" try: @@ -76,7 +76,7 @@ def _handle_list_repos(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_get_repo(**kwargs) -> str: +def handle_get_repo(**kwargs) -> str: """Get details of a GitHub repository.""" service_name = "github" try: @@ -113,7 +113,7 @@ def _handle_get_repo(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_create_issue(**kwargs) -> str: +def handle_create_issue(**kwargs) -> str: """Create a GitHub issue.""" service_name = "github" try: @@ -149,7 +149,7 @@ def _handle_create_issue(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_create_pull_request(**kwargs) -> str: +def handle_create_pull_request(**kwargs) -> str: """Create a GitHub pull request.""" service_name = "github" try: @@ -192,7 +192,7 @@ def _handle_create_pull_request(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_get_file_content(**kwargs) -> str: +def handle_get_file_content(**kwargs) -> str: """Get file content from a GitHub repository.""" service_name = "github" try: @@ -227,7 +227,7 @@ def _handle_get_file_content(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_search_code(**kwargs) -> str: +def handle_search_code(**kwargs) -> str: """Search code across GitHub.""" service_name = "github" try: @@ -258,20 +258,3 @@ def _handle_search_code(**kwargs) -> str: frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) return json.dumps({"success": False, "error": str(e)}) - - -def handle_action(**kwargs) -> str: - action = kwargs.get("action", "").strip().lower() - dispatch = { - "list_repos": _handle_list_repos, - "get_repo": _handle_get_repo, - "create_issue": _handle_create_issue, - "create_pr": _handle_create_pull_request, - "get_file": _handle_get_file_content, - "search_code": _handle_search_code, - } - handler = dispatch.get(action) - if not handler: - valid = ", ".join(sorted(dispatch.keys())) - return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) - return handler(**kwargs) diff --git a/huf/ai/tools/slack.py b/huf/ai/tools/slack.py index 7d548885..3c1f87d9 100644 --- a/huf/ai/tools/slack.py +++ b/huf/ai/tools/slack.py @@ -21,7 +21,7 @@ def _get_slack_headers(): } -def _handle_send_message(**kwargs) -> str: +def handle_send_message(**kwargs) -> str: """Send a message to a Slack channel.""" service_name = "slack" try: @@ -55,7 +55,7 @@ def _handle_send_message(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_send_message_thread(**kwargs) -> str: +def handle_send_message_thread(**kwargs) -> str: """Reply to a message thread in a Slack channel.""" service_name = "slack" try: @@ -90,7 +90,7 @@ def _handle_send_message_thread(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_list_channels(**kwargs) -> str: +def handle_list_channels(**kwargs) -> str: """List all channels in the Slack workspace.""" service_name = "slack" try: @@ -125,7 +125,7 @@ def _handle_list_channels(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_get_channel_history(**kwargs) -> str: +def handle_get_channel_history(**kwargs) -> str: """Get message history of a Slack channel.""" service_name = "slack" try: @@ -165,7 +165,7 @@ def _handle_get_channel_history(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_search_messages(**kwargs) -> str: +def handle_search_messages(**kwargs) -> str: """Search messages across the Slack workspace.""" service_name = "slack" try: @@ -206,7 +206,7 @@ def _handle_search_messages(**kwargs) -> str: return json.dumps({"success": False, "error": str(e)}) -def _handle_list_users(**kwargs) -> str: +def handle_list_users(**kwargs) -> str: """List all users in the Slack workspace.""" service_name = "slack" try: @@ -241,20 +241,3 @@ def _handle_list_users(**kwargs) -> str: frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) return json.dumps({"success": False, "error": str(e)}) - - -def handle_action(**kwargs) -> str: - action = kwargs.get("action", "").strip().lower() - dispatch = { - "send_message": _handle_send_message, - "reply_thread": _handle_send_message_thread, - "list_channels": _handle_list_channels, - "get_history": _handle_get_channel_history, - "search_messages": _handle_search_messages, - "list_users": _handle_list_users, - } - handler = dispatch.get(action) - if not handler: - valid = ", ".join(sorted(dispatch.keys())) - return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) - return handler(**kwargs) diff --git a/huf/ai/tools/telegram.py b/huf/ai/tools/telegram.py index a64f5dd4..9c0ebdb5 100644 --- a/huf/ai/tools/telegram.py +++ b/huf/ai/tools/telegram.py @@ -9,7 +9,7 @@ from huf.ai.tools.credentials import require_credential, get_credential, update_last_error -def _handle_send_message(**kwargs) -> str: +def handle_send_message(**kwargs) -> str: """Send a message via Telegram bot.""" service_name = "telegram" try: @@ -44,15 +44,3 @@ def _handle_send_message(**kwargs) -> str: frappe.log_error(error_msg, "Telegram Tool") update_last_error(service_name, error_msg) return json.dumps({"success": False, "error": str(e)}) - - -def handle_action(**kwargs) -> str: - action = kwargs.get("action", "").strip().lower() - dispatch = { - "send_message": _handle_send_message, - } - handler = dispatch.get(action) - if not handler: - valid = ", ".join(sorted(dispatch.keys())) - return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) - return handler(**kwargs) From cf3f20efa973f3a517f6379fc4150d6cedcd0014 Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Tue, 9 Jun 2026 09:20:40 +0000 Subject: [PATCH 07/12] fix: handle datetime and non-serializable objects in AI tool responses --- huf/ai/tools/crm.py | 50 +++++++++++++++---------------- huf/ai/tools/discord.py | 16 +++++----- huf/ai/tools/erpnext.py | 30 +++++++++---------- huf/ai/tools/erpnext_crm.py | 12 ++++---- huf/ai/tools/erpnext_inventory.py | 28 ++++++++--------- huf/ai/tools/erpnext_reports.py | 8 ++--- huf/ai/tools/github.py | 22 +++++++------- huf/ai/tools/helpdesk.py | 20 ++++++------- huf/ai/tools/raven.py | 16 +++++----- huf/ai/tools/recipient.py | 2 +- huf/ai/tools/slack.py | 36 +++++++++++----------- huf/ai/tools/telegram.py | 6 ++-- 12 files changed, 122 insertions(+), 124 deletions(-) diff --git a/huf/ai/tools/crm.py b/huf/ai/tools/crm.py index 5b377c04..ac26aec8 100644 --- a/huf/ai/tools/crm.py +++ b/huf/ai/tools/crm.py @@ -15,7 +15,7 @@ def _crm_installed(): def _error(msg): - return json.dumps({"success": False, "error": msg}) + return json.dumps({"success": False, "error": msg}, default=str) # --------------------------------------------------------------------------- @@ -71,7 +71,7 @@ def _handle_get_leads(**kwargs) -> str: order_by="modified desc", ) - return json.dumps({"success": True, "count": len(leads), "results": leads}) + return json.dumps({"success": True, "count": len(leads), "results": leads}, default=str) except Exception as e: frappe.log_error(f"CRM Get Leads Error: {e}", "CRM Tool") return _error(str(e)) @@ -91,7 +91,7 @@ def _handle_get_lead(**kwargs) -> str: return _error(f"Lead {name} not found") doc = frappe.get_doc("CRM Lead", name) - return json.dumps({"success": True, "results": doc.as_dict()}) + return json.dumps({"success": True, "results": doc.as_dict()}, default=str) except Exception as e: frappe.log_error(f"CRM Get Lead Error: {e}", "CRM Tool") return _error(str(e)) @@ -131,7 +131,7 @@ def _handle_create_lead(**kwargs) -> str: note.reference_docname = doc.name note.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}}) + return json.dumps({"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}}, default=str) except Exception as e: frappe.log_error(f"CRM Create Lead Error: {e}", "CRM Tool") return _error(str(e)) @@ -176,7 +176,7 @@ def _handle_update_lead(**kwargs) -> str: setattr(doc, field, kwargs[field]) doc.save(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}}) + return json.dumps({"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}}, default=str) except Exception as e: frappe.log_error(f"CRM Update Lead Error: {e}", "CRM Tool") return _error(str(e)) @@ -219,11 +219,9 @@ def _handle_get_deals(**kwargs) -> str: "organization", "status", "deal_owner", - "deal_value", - "expected_deal_value", + "annual_revenue", "probability", - "expected_closure_date", - "closed_date", + "close_date", "modified", ] @@ -236,7 +234,7 @@ def _handle_get_deals(**kwargs) -> str: order_by="modified desc", ) - return json.dumps({"success": True, "count": len(deals), "results": deals}) + return json.dumps({"success": True, "count": len(deals), "results": deals}, default=str) except Exception as e: frappe.log_error(f"CRM Get Deals Error: {e}", "CRM Tool") return _error(str(e)) @@ -255,7 +253,7 @@ def _handle_get_deal(**kwargs) -> str: return _error(f"Deal {name} not found") doc = frappe.get_doc("CRM Deal", name) - return json.dumps({"success": True, "results": doc.as_dict()}) + return json.dumps({"success": True, "results": doc.as_dict()}, default=str) except Exception as e: frappe.log_error(f"CRM Get Deal Error: {e}", "CRM Tool") return _error(str(e)) @@ -307,18 +305,16 @@ def _handle_create_deal(**kwargs) -> str: doc.status = kwargs.get("status", "Qualification") doc.deal_owner = kwargs.get("deal_owner", "") - doc.deal_value = kwargs.get("deal_value", 0) - doc.expected_deal_value = kwargs.get("expected_deal_value", 0) + doc.annual_revenue = kwargs.get("deal_value", kwargs.get("annual_revenue", 0)) doc.probability = kwargs.get("probability", 0) - doc.expected_closure_date = kwargs.get("expected_closure_date", "") - doc.closed_date = kwargs.get("closed_date", "") + doc.close_date = kwargs.get("expected_closure_date", kwargs.get("close_date", "")) doc.email = kwargs.get("email", doc.email or "") doc.mobile_no = kwargs.get("mobile_no", doc.mobile_no or "") doc.first_name = kwargs.get("first_name", doc.first_name or "") doc.last_name = kwargs.get("last_name", doc.last_name or "") doc.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name, "organization": doc.organization_name or doc.organization}}) + return json.dumps({"success": True, "results": {"name": doc.name, "organization": doc.organization_name or doc.organization}}, default=str) except Exception as e: frappe.log_error(f"CRM Create Deal Error: {e}", "CRM Tool") return _error(str(e)) @@ -341,11 +337,9 @@ def _handle_update_deal(**kwargs) -> str: "organization", "status", "deal_owner", - "deal_value", - "expected_deal_value", + "annual_revenue", "probability", - "expected_closure_date", - "closed_date", + "close_date", "next_step", "email", "mobile_no", @@ -353,7 +347,6 @@ def _handle_update_deal(**kwargs) -> str: "website", "territory", "industry", - "annual_revenue", "lost_reason", "lost_notes", ] @@ -361,8 +354,13 @@ def _handle_update_deal(**kwargs) -> str: if field in kwargs: setattr(doc, field, kwargs[field]) + if "deal_value" in kwargs: + doc.annual_revenue = kwargs["deal_value"] + if "expected_closure_date" in kwargs: + doc.close_date = kwargs["expected_closure_date"] + doc.save(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name, "organization": doc.organization}}) + return json.dumps({"success": True, "results": {"name": doc.name, "organization": doc.organization}}, default=str) except Exception as e: frappe.log_error(f"CRM Update Deal Error: {e}", "CRM Tool") return _error(str(e)) @@ -394,7 +392,7 @@ def _handle_add_note(**kwargs) -> str: note.reference_docname = docname note.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": note.name, "title": note.title}}) + return json.dumps({"success": True, "results": {"name": note.name, "title": note.title}}, default=str) except Exception as e: frappe.log_error(f"CRM Add Note Error: {e}", "CRM Tool") return _error(str(e)) @@ -427,7 +425,7 @@ def _handle_add_task(**kwargs) -> str: task.start_date = kwargs.get("start_date", "") task.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": task.name, "title": task.title}}) + return json.dumps({"success": True, "results": {"name": task.name, "title": task.title}}, default=str) except Exception as e: frappe.log_error(f"CRM Add Task Error: {e}", "CRM Tool") return _error(str(e)) @@ -514,7 +512,7 @@ def _handle_get_contacts(**kwargs) -> str: order_by="modified desc", ) - return json.dumps({"success": True, "count": len(contacts), "results": contacts}) + return json.dumps({"success": True, "count": len(contacts), "results": contacts}, default=str) except frappe.DoesNotExistError: return _error("Contact DocType does not exist on this site.") except Exception as e: @@ -540,5 +538,5 @@ def handle_action(**kwargs) -> str: handler = dispatch.get(action) if not handler: valid = ", ".join(sorted(dispatch.keys())) - return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) return handler(**kwargs) diff --git a/huf/ai/tools/discord.py b/huf/ai/tools/discord.py index 83878d69..4fd7203f 100644 --- a/huf/ai/tools/discord.py +++ b/huf/ai/tools/discord.py @@ -28,7 +28,7 @@ def handle_send_message(**kwargs) -> str: channel_id = kwargs.get("channel_id") message = kwargs.get("message") if not all([channel_id, message]): - return json.dumps({"success": False, "error": "channel_id and message are required"}) + return json.dumps({"success": False, "error": "channel_id and message are required"}, default=str) headers = _get_discord_headers() payload = {"content": message} @@ -49,7 +49,7 @@ def handle_send_message(**kwargs) -> str: error_msg = f"Discord Send Message Error: {str(e)}" frappe.log_error(error_msg, "Discord Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_get_channel_messages(**kwargs) -> str: @@ -58,7 +58,7 @@ def handle_get_channel_messages(**kwargs) -> str: try: channel_id = kwargs.get("channel_id") if not channel_id: - return json.dumps({"success": False, "error": "channel_id is required"}) + return json.dumps({"success": False, "error": "channel_id is required"}, default=str) limit = int(kwargs.get("limit", 50)) headers = _get_discord_headers() @@ -84,7 +84,7 @@ def handle_get_channel_messages(**kwargs) -> str: error_msg = f"Discord Get Messages Error: {str(e)}" frappe.log_error(error_msg, "Discord Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_list_channels(**kwargs) -> str: @@ -93,7 +93,7 @@ def handle_list_channels(**kwargs) -> str: try: guild_id = kwargs.get("guild_id") if not guild_id: - return json.dumps({"success": False, "error": "guild_id is required"}) + return json.dumps({"success": False, "error": "guild_id is required"}, default=str) headers = _get_discord_headers() @@ -117,7 +117,7 @@ def handle_list_channels(**kwargs) -> str: error_msg = f"Discord List Channels Error: {str(e)}" frappe.log_error(error_msg, "Discord Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_delete_message(**kwargs) -> str: @@ -127,7 +127,7 @@ def handle_delete_message(**kwargs) -> str: channel_id = kwargs.get("channel_id") message_id = kwargs.get("message_id") if not all([channel_id, message_id]): - return json.dumps({"success": False, "error": "channel_id and message_id are required"}) + return json.dumps({"success": False, "error": "channel_id and message_id are required"}, default=str) headers = _get_discord_headers() @@ -146,4 +146,4 @@ def handle_delete_message(**kwargs) -> str: error_msg = f"Discord Delete Message Error: {str(e)}" frappe.log_error(error_msg, "Discord Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) diff --git a/huf/ai/tools/erpnext.py b/huf/ai/tools/erpnext.py index 380df387..fa487319 100644 --- a/huf/ai/tools/erpnext.py +++ b/huf/ai/tools/erpnext.py @@ -15,7 +15,7 @@ def _erpnext_installed(): def _error(msg): - return json.dumps({"success": False, "error": msg}) + return json.dumps({"success": False, "error": msg}, default=str) def _docstatus_label(ds): @@ -80,7 +80,7 @@ def _handle_get_sales_invoices(**kwargs) -> str: for inv in invoices: inv["docstatus_label"] = _docstatus_label(inv.get("docstatus")) - return json.dumps({"success": True, "count": len(invoices), "results": invoices}) + return json.dumps({"success": True, "count": len(invoices), "results": invoices}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Sales Invoices Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -101,7 +101,7 @@ def _handle_get_sales_invoice(**kwargs) -> str: doc = frappe.get_doc("Sales Invoice", name) result = doc.as_dict() result["docstatus_label"] = _docstatus_label(result.get("docstatus")) - return json.dumps({"success": True, "results": result}) + return json.dumps({"success": True, "results": result}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Sales Invoice Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -204,7 +204,7 @@ def _handle_get_purchase_invoices(**kwargs) -> str: for inv in invoices: inv["docstatus_label"] = _docstatus_label(inv.get("docstatus")) - return json.dumps({"success": True, "count": len(invoices), "results": invoices}) + return json.dumps({"success": True, "count": len(invoices), "results": invoices}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Purchase Invoices Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -225,7 +225,7 @@ def _handle_get_purchase_invoice(**kwargs) -> str: doc = frappe.get_doc("Purchase Invoice", name) result = doc.as_dict() result["docstatus_label"] = _docstatus_label(result.get("docstatus")) - return json.dumps({"success": True, "results": result}) + return json.dumps({"success": True, "results": result}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Purchase Invoice Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -280,7 +280,7 @@ def _handle_get_payments(**kwargs) -> str: order_by="posting_date desc", ) - return json.dumps({"success": True, "count": len(payments), "results": payments}) + return json.dumps({"success": True, "count": len(payments), "results": payments}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Payments Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -333,7 +333,7 @@ def _handle_create_payment(**kwargs) -> str: ) doc.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name}}) + return json.dumps({"success": True, "results": {"name": doc.name}}, default=str) except Exception as e: frappe.log_error(f"ERPNext Create Payment Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -391,7 +391,7 @@ def _handle_get_quotations(**kwargs) -> str: for q in quotes: q["docstatus_label"] = _docstatus_label(q.get("docstatus")) - return json.dumps({"success": True, "count": len(quotes), "results": quotes}) + return json.dumps({"success": True, "count": len(quotes), "results": quotes}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Quotations Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -431,7 +431,7 @@ def _handle_create_quotation(**kwargs) -> str: ) doc.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name}}) + return json.dumps({"success": True, "results": {"name": doc.name}}, default=str) except Exception as e: frappe.log_error(f"ERPNext Create Quotation Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -485,7 +485,7 @@ def _handle_get_customers(**kwargs) -> str: order_by="modified desc", ) - return json.dumps({"success": True, "count": len(customers), "results": customers}) + return json.dumps({"success": True, "count": len(customers), "results": customers}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Customers Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -536,7 +536,7 @@ def _handle_get_customer(**kwargs) -> str: contact = frappe.get_doc("Contact", link.parent) result["contacts"].append(contact.as_dict()) - return json.dumps({"success": True, "results": result}) + return json.dumps({"success": True, "results": result}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Customer Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -599,7 +599,7 @@ def _handle_get_account_ledger(**kwargs) -> str: running_balance += float(entry.get("debit", 0) or 0) - float(entry.get("credit", 0) or 0) entry["running_balance"] = running_balance - return json.dumps({"success": True, "count": len(entries), "results": entries}) + return json.dumps({"success": True, "count": len(entries), "results": entries}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Account Ledger Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -638,7 +638,7 @@ def _handle_create_journal_entry(**kwargs) -> str: ) doc.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name}}) + return json.dumps({"success": True, "results": {"name": doc.name}}, default=str) except Exception as e: frappe.log_error(f"ERPNext Create Journal Entry Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -677,7 +677,7 @@ def _handle_get_rfqs(**kwargs) -> str: order_by="transaction_date desc", ) - return json.dumps({"success": True, "count": len(rfqs), "results": rfqs}) + return json.dumps({"success": True, "count": len(rfqs), "results": rfqs}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get RFQs Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -704,5 +704,5 @@ def handle_action(**kwargs) -> str: handler = dispatch.get(action) if not handler: valid = ", ".join(sorted(dispatch.keys())) - return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) return handler(**kwargs) diff --git a/huf/ai/tools/erpnext_crm.py b/huf/ai/tools/erpnext_crm.py index ea9c2e25..137f96b9 100644 --- a/huf/ai/tools/erpnext_crm.py +++ b/huf/ai/tools/erpnext_crm.py @@ -16,7 +16,7 @@ def _erpnext_installed(): def _error(msg): - return json.dumps({"success": False, "error": msg}) + return json.dumps({"success": False, "error": msg}, default=str) # --------------------------------------------------------------------------- @@ -69,7 +69,7 @@ def _handle_get_leads(**kwargs) -> str: order_by="modified desc", ) - return json.dumps({"success": True, "count": len(leads), "results": leads}) + return json.dumps({"success": True, "count": len(leads), "results": leads}, default=str) except Exception as e: frappe.log_error(f"ERPNext CRM Get Leads Error: {e}", "ERPNext CRM Tool") return _error(str(e)) @@ -88,7 +88,7 @@ def _handle_get_lead(**kwargs) -> str: return _error(f"Lead {name} not found") doc = frappe.get_doc("Lead", name) - return json.dumps({"success": True, "results": doc.as_dict()}) + return json.dumps({"success": True, "results": doc.as_dict()}, default=str) except Exception as e: frappe.log_error(f"ERPNext CRM Get Lead Error: {e}", "ERPNext CRM Tool") return _error(str(e)) @@ -243,7 +243,7 @@ def _handle_create_opportunity(**kwargs) -> str: doc.currency = kwargs.get("currency", "") doc.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name}}) + return json.dumps({"success": True, "results": {"name": doc.name}}, default=str) except Exception as e: frappe.log_error(f"ERPNext CRM Create Opportunity Error: {e}", "ERPNext CRM Tool") return _error(str(e)) @@ -278,7 +278,7 @@ def _handle_update_opportunity(**kwargs) -> str: setattr(doc, field, kwargs[field]) doc.save(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name}}) + return json.dumps({"success": True, "results": {"name": doc.name}}, default=str) except Exception as e: frappe.log_error(f"ERPNext CRM Update Opportunity Error: {e}", "ERPNext CRM Tool") return _error(str(e)) @@ -298,5 +298,5 @@ def handle_action(**kwargs) -> str: handler = dispatch.get(action) if not handler: valid = ", ".join(sorted(dispatch.keys())) - return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) return handler(**kwargs) diff --git a/huf/ai/tools/erpnext_inventory.py b/huf/ai/tools/erpnext_inventory.py index 42226bc9..91812033 100644 --- a/huf/ai/tools/erpnext_inventory.py +++ b/huf/ai/tools/erpnext_inventory.py @@ -15,7 +15,7 @@ def _erpnext_installed(): def _error(msg): - return json.dumps({"success": False, "error": msg}) + return json.dumps({"success": False, "error": msg}, default=str) def _docstatus_label(ds): @@ -72,7 +72,7 @@ def _handle_get_items(**kwargs) -> str: order_by="modified desc", ) - return json.dumps({"success": True, "count": len(items), "results": items}) + return json.dumps({"success": True, "count": len(items), "results": items}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Items Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -92,7 +92,7 @@ def _handle_get_item(**kwargs) -> str: doc = frappe.get_doc("Item", name) result = doc.as_dict() - return json.dumps({"success": True, "results": result}) + return json.dumps({"success": True, "results": result}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Item Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -140,7 +140,7 @@ def _handle_get_item_prices(**kwargs) -> str: order_by="modified desc", ) - return json.dumps({"success": True, "count": len(prices), "results": prices}) + return json.dumps({"success": True, "count": len(prices), "results": prices}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Item Prices Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -193,7 +193,7 @@ def _handle_get_boms(**kwargs) -> str: order_by="modified desc", ) - return json.dumps({"success": True, "count": len(boms), "results": boms}) + return json.dumps({"success": True, "count": len(boms), "results": boms}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get BOMs Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -213,7 +213,7 @@ def _handle_get_bom(**kwargs) -> str: doc = frappe.get_doc("BOM", name) result = doc.as_dict() - return json.dumps({"success": True, "results": result}) + return json.dumps({"success": True, "results": result}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get BOM Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -252,7 +252,7 @@ def _handle_create_bom(**kwargs) -> str: ) doc.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name, "item": doc.item}}) + return json.dumps({"success": True, "results": {"name": doc.name, "item": doc.item}}, default=str) except Exception as e: frappe.log_error(f"ERPNext Create BOM Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -313,7 +313,7 @@ def _handle_get_stock_balance(**kwargs) -> str: ), }) - return json.dumps({"success": True, "count": len(results), "results": results}) + return json.dumps({"success": True, "count": len(results), "results": results}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Stock Balance Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -366,7 +366,7 @@ def _handle_get_stock_movements(**kwargs) -> str: order_by="posting_date desc, posting_time desc", ) - return json.dumps({"success": True, "count": len(entries), "results": entries}) + return json.dumps({"success": True, "count": len(entries), "results": entries}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Stock Movements Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -413,7 +413,7 @@ def _handle_get_stock_entries(**kwargs) -> str: for entry in entries: entry["docstatus_label"] = _docstatus_label(entry.get("docstatus")) - return json.dumps({"success": True, "count": len(entries), "results": entries}) + return json.dumps({"success": True, "count": len(entries), "results": entries}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Stock Entries Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -452,7 +452,7 @@ def _handle_get_warehouses(**kwargs) -> str: order_by="warehouse_name asc", ) - return json.dumps({"success": True, "count": len(warehouses), "results": warehouses}) + return json.dumps({"success": True, "count": len(warehouses), "results": warehouses}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Warehouses Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -500,7 +500,7 @@ def _handle_get_delivery_notes(**kwargs) -> str: for note in notes: note["docstatus_label"] = _docstatus_label(note.get("docstatus")) - return json.dumps({"success": True, "count": len(notes), "results": notes}) + return json.dumps({"success": True, "count": len(notes), "results": notes}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Delivery Notes Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -548,7 +548,7 @@ def _handle_get_purchase_receipts(**kwargs) -> str: for receipt in receipts: receipt["docstatus_label"] = _docstatus_label(receipt.get("docstatus")) - return json.dumps({"success": True, "count": len(receipts), "results": receipts}) + return json.dumps({"success": True, "count": len(receipts), "results": receipts}, default=str) except Exception as e: frappe.log_error(f"ERPNext Get Purchase Receipts Error: {e}", "ERPNext Tool") return _error(str(e)) @@ -573,5 +573,5 @@ def handle_action(**kwargs) -> str: handler = dispatch.get(action) if not handler: valid = ", ".join(sorted(dispatch.keys())) - return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) return handler(**kwargs) diff --git a/huf/ai/tools/erpnext_reports.py b/huf/ai/tools/erpnext_reports.py index d84c2747..7bde2997 100644 --- a/huf/ai/tools/erpnext_reports.py +++ b/huf/ai/tools/erpnext_reports.py @@ -15,7 +15,7 @@ def _erpnext_installed(): def _error(msg): - return json.dumps({"success": False, "error": msg}) + return json.dumps({"success": False, "error": msg}, default=str) # Catalogue of available reports per module (used by list_reports) @@ -120,6 +120,6 @@ def handle_list_reports(**kwargs) -> str: matched = {k: v for k, v in REPORT_CATALOGUE.items() if k.lower() == module.lower()} if not matched: available = list(REPORT_CATALOGUE.keys()) - return json.dumps({"success": False, "error": f"Module '{module}' not found. Available: {available}"}) - return json.dumps({"success": True, "results": matched}) - return json.dumps({"success": True, "results": REPORT_CATALOGUE}) + return json.dumps({"success": False, "error": f"Module '{module}' not found. Available: {available}"}, default=str) + return json.dumps({"success": True, "results": matched}, default=str) + return json.dumps({"success": True, "results": REPORT_CATALOGUE}, default=str) diff --git a/huf/ai/tools/github.py b/huf/ai/tools/github.py index 7a0b6154..db77c271 100644 --- a/huf/ai/tools/github.py +++ b/huf/ai/tools/github.py @@ -73,7 +73,7 @@ def handle_list_repos(**kwargs) -> str: error_msg = f"GitHub List Repos Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_get_repo(**kwargs) -> str: @@ -82,7 +82,7 @@ def handle_get_repo(**kwargs) -> str: try: repo_name = kwargs.get("repo_name") if not repo_name: - return json.dumps({"success": False, "error": "repo_name is required"}) + return json.dumps({"success": False, "error": "repo_name is required"}, default=str) data = _make_github_request("GET", f"repos/{repo_name}") @@ -110,7 +110,7 @@ def handle_get_repo(**kwargs) -> str: error_msg = f"GitHub Get Repo Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_create_issue(**kwargs) -> str: @@ -120,7 +120,7 @@ def handle_create_issue(**kwargs) -> str: repo_name = kwargs.get("repo_name") title = kwargs.get("title") if not all([repo_name, title]): - return json.dumps({"success": False, "error": "repo_name and title are required"}) + return json.dumps({"success": False, "error": "repo_name and title are required"}, default=str) body = kwargs.get("body") payload = {"title": title} @@ -146,7 +146,7 @@ def handle_create_issue(**kwargs) -> str: error_msg = f"GitHub Create Issue Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_create_pull_request(**kwargs) -> str: @@ -159,7 +159,7 @@ def handle_create_pull_request(**kwargs) -> str: head = kwargs.get("head") base = kwargs.get("base") if not all([repo_name, title, body, head, base]): - return json.dumps({"success": False, "error": "repo_name, title, body, head, and base are required"}) + return json.dumps({"success": False, "error": "repo_name, title, body, head, and base are required"}, default=str) payload = { "title": title, @@ -189,7 +189,7 @@ def handle_create_pull_request(**kwargs) -> str: error_msg = f"GitHub Create PR Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_get_file_content(**kwargs) -> str: @@ -199,7 +199,7 @@ def handle_get_file_content(**kwargs) -> str: repo_name = kwargs.get("repo_name") path = kwargs.get("path") if not all([repo_name, path]): - return json.dumps({"success": False, "error": "repo_name and path are required"}) + return json.dumps({"success": False, "error": "repo_name and path are required"}, default=str) data = _make_github_request("GET", f"repos/{repo_name}/contents/{path}") @@ -224,7 +224,7 @@ def handle_get_file_content(**kwargs) -> str: error_msg = f"GitHub Get File Content Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_search_code(**kwargs) -> str: @@ -233,7 +233,7 @@ def handle_search_code(**kwargs) -> str: try: query = kwargs.get("query") if not query: - return json.dumps({"success": False, "error": "query is required"}) + return json.dumps({"success": False, "error": "query is required"}, default=str) data = _make_github_request("GET", "search/code", params={"q": query, "per_page": 30}) @@ -257,4 +257,4 @@ def handle_search_code(**kwargs) -> str: error_msg = f"GitHub Search Code Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) diff --git a/huf/ai/tools/helpdesk.py b/huf/ai/tools/helpdesk.py index 6c0235ba..5ea04e33 100644 --- a/huf/ai/tools/helpdesk.py +++ b/huf/ai/tools/helpdesk.py @@ -15,7 +15,7 @@ def _helpdesk_installed(): def _error(msg): - return json.dumps({"success": False, "error": msg}) + return json.dumps({"success": False, "error": msg}, default=str) # --------------------------------------------------------------------------- @@ -78,7 +78,7 @@ def _handle_get_tickets(**kwargs) -> str: order_by="modified desc", ) - return json.dumps({"success": True, "count": len(tickets), "results": tickets}) + return json.dumps({"success": True, "count": len(tickets), "results": tickets}, default=str) except Exception as e: frappe.log_error(f"Helpdesk Get Tickets Error: {e}", "Helpdesk Tool") return _error(str(e)) @@ -107,7 +107,7 @@ def _handle_get_ticket(**kwargs) -> str: result = doc.as_dict() result["comments"] = comments - return json.dumps({"success": True, "results": result}) + return json.dumps({"success": True, "results": result}, default=str) except Exception as e: frappe.log_error(f"Helpdesk Get Ticket Error: {e}", "Helpdesk Tool") return _error(str(e)) @@ -134,7 +134,7 @@ def _handle_create_ticket(**kwargs) -> str: doc.contact = kwargs.get("contact", "") doc.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name, "subject": doc.subject}}) + return json.dumps({"success": True, "results": {"name": doc.name, "subject": doc.subject}}, default=str) except Exception as e: frappe.log_error(f"Helpdesk Create Ticket Error: {e}", "Helpdesk Tool") return _error(str(e)) @@ -177,7 +177,7 @@ def _handle_update_ticket(**kwargs) -> str: return _error(f"Agent {assigned_to} not found") doc.assign_agent(assigned_to) - return json.dumps({"success": True, "results": {"name": doc.name, "subject": doc.subject}}) + return json.dumps({"success": True, "results": {"name": doc.name, "subject": doc.subject}}, default=str) except Exception as e: frappe.log_error(f"Helpdesk Update Ticket Error: {e}", "Helpdesk Tool") return _error(str(e)) @@ -202,7 +202,7 @@ def _handle_add_comment(**kwargs) -> str: comment.commented_by = kwargs.get("commented_by", frappe.session.user) comment.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": comment.name}}) + return json.dumps({"success": True, "results": {"name": comment.name}}, default=str) except Exception as e: frappe.log_error(f"Helpdesk Add Comment Error: {e}", "Helpdesk Tool") return _error(str(e)) @@ -242,7 +242,7 @@ def _handle_get_agents(**kwargs) -> str: limit_start=offset, order_by="modified desc", ) - return json.dumps({"success": True, "count": len(agents), "results": agents}) + return json.dumps({"success": True, "count": len(agents), "results": agents}, default=str) except Exception as e: frappe.log_error(f"Helpdesk Get Agents Error: {e}", "Helpdesk Tool") return _error(str(e)) @@ -271,7 +271,7 @@ def _handle_get_teams(**kwargs) -> str: limit_start=offset, order_by="modified desc", ) - return json.dumps({"success": True, "count": len(teams), "results": teams}) + return json.dumps({"success": True, "count": len(teams), "results": teams}, default=str) except Exception as e: frappe.log_error(f"Helpdesk Get Teams Error: {e}", "Helpdesk Tool") return _error(str(e)) @@ -295,7 +295,7 @@ def _handle_assign_ticket(**kwargs) -> str: doc = frappe.get_doc("HD Ticket", ticket_id) doc.assign_agent(agent_id) - return json.dumps({"success": True, "results": {"name": doc.name, "assigned_to": agent_id}}) + return json.dumps({"success": True, "results": {"name": doc.name, "assigned_to": agent_id}}, default=str) except Exception as e: frappe.log_error(f"Helpdesk Assign Ticket Error: {e}", "Helpdesk Tool") return _error(str(e)) @@ -316,5 +316,5 @@ def handle_action(**kwargs) -> str: handler = dispatch.get(action) if not handler: valid = ", ".join(sorted(dispatch.keys())) - return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) return handler(**kwargs) diff --git a/huf/ai/tools/raven.py b/huf/ai/tools/raven.py index 0de816f9..c6aa8600 100644 --- a/huf/ai/tools/raven.py +++ b/huf/ai/tools/raven.py @@ -15,7 +15,7 @@ def _raven_installed(): def _error(msg): - return json.dumps({"success": False, "error": msg}) + return json.dumps({"success": False, "error": msg}, default=str) def _resolve_channel_id(channel_id=None, channel_name=None): @@ -53,7 +53,7 @@ def _handle_send_message(**kwargs) -> str: doc.message_type = kwargs.get("message_type", "Text") doc.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name, "channel_id": channel_id}}) + return json.dumps({"success": True, "results": {"name": doc.name, "channel_id": channel_id}}, default=str) except Exception as e: frappe.log_error(f"Raven Send Message Error: {e}", "Raven Tool") return _error(str(e)) @@ -102,7 +102,7 @@ def _handle_get_messages(**kwargs) -> str: order_by="creation desc", ) - return json.dumps({"success": True, "count": len(messages), "results": messages}) + return json.dumps({"success": True, "count": len(messages), "results": messages}, default=str) except Exception as e: frappe.log_error(f"Raven Get Messages Error: {e}", "Raven Tool") return _error(str(e)) @@ -147,7 +147,7 @@ def _handle_list_channels(**kwargs) -> str: order_by="modified desc", ) - return json.dumps({"success": True, "count": len(channels), "results": channels}) + return json.dumps({"success": True, "count": len(channels), "results": channels}, default=str) except Exception as e: frappe.log_error(f"Raven List Channels Error: {e}", "Raven Tool") return _error(str(e)) @@ -177,7 +177,7 @@ def _handle_get_channel_members(**kwargs) -> str: order_by="creation asc", ) - return json.dumps({"success": True, "count": len(members), "results": members}) + return json.dumps({"success": True, "count": len(members), "results": members}, default=str) except Exception as e: frappe.log_error(f"Raven Get Channel Members Error: {e}", "Raven Tool") return _error(str(e)) @@ -229,7 +229,7 @@ def _handle_create_channel(**kwargs) -> str: member.user_id = user_id member.insert(ignore_permissions=True) - return json.dumps({"success": True, "results": {"name": doc.name, "channel_name": doc.channel_name}}) + return json.dumps({"success": True, "results": {"name": doc.name, "channel_name": doc.channel_name}}, default=str) except Exception as e: frappe.log_error(f"Raven Create Channel Error: {e}", "Raven Tool") return _error(str(e)) @@ -279,7 +279,7 @@ def _handle_search_messages(**kwargs) -> str: order_by="creation desc", ) - return json.dumps({"success": True, "count": len(messages), "results": messages}) + return json.dumps({"success": True, "count": len(messages), "results": messages}, default=str) except Exception as e: frappe.log_error(f"Raven Search Messages Error: {e}", "Raven Tool") return _error(str(e)) @@ -298,5 +298,5 @@ def handle_action(**kwargs) -> str: handler = dispatch.get(action) if not handler: valid = ", ".join(sorted(dispatch.keys())) - return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) return handler(**kwargs) diff --git a/huf/ai/tools/recipient.py b/huf/ai/tools/recipient.py index 39721d96..fdce1bd4 100644 --- a/huf/ai/tools/recipient.py +++ b/huf/ai/tools/recipient.py @@ -88,4 +88,4 @@ def handle_get_recipient(**kwargs) -> str: except Exception as e: frappe.log_error(f"get_integration_recipient error: {str(e)}", "Integration Recipient Tool") - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) diff --git a/huf/ai/tools/slack.py b/huf/ai/tools/slack.py index 3c1f87d9..adbc1999 100644 --- a/huf/ai/tools/slack.py +++ b/huf/ai/tools/slack.py @@ -28,7 +28,7 @@ def handle_send_message(**kwargs) -> str: channel = kwargs.get("channel") text = kwargs.get("text") if not all([channel, text]): - return json.dumps({"success": False, "error": "channel and text are required"}) + return json.dumps({"success": False, "error": "channel and text are required"}, default=str) headers = _get_slack_headers() payload = {"channel": channel, "text": text, "mrkdwn": True} @@ -45,14 +45,14 @@ def handle_send_message(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) - return json.dumps({"success": True, "results": data}) + return json.dumps({"success": True, "results": data}, default=str) except Exception as e: error_msg = f"Slack Send Message Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_send_message_thread(**kwargs) -> str: @@ -63,7 +63,7 @@ def handle_send_message_thread(**kwargs) -> str: text = kwargs.get("text") thread_ts = kwargs.get("thread_ts") if not all([channel, text, thread_ts]): - return json.dumps({"success": False, "error": "channel, text, and thread_ts are required"}) + return json.dumps({"success": False, "error": "channel, text, and thread_ts are required"}, default=str) headers = _get_slack_headers() payload = {"channel": channel, "text": text, "thread_ts": thread_ts, "mrkdwn": True} @@ -80,14 +80,14 @@ def handle_send_message_thread(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) - return json.dumps({"success": True, "results": data}) + return json.dumps({"success": True, "results": data}, default=str) except Exception as e: error_msg = f"Slack Thread Reply Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_list_channels(**kwargs) -> str: @@ -108,7 +108,7 @@ def handle_list_channels(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) channels = [{"id": ch.get("id"), "name": ch.get("name"), "is_private": ch.get("is_private", False)} for ch in data.get("channels", [])] @@ -122,7 +122,7 @@ def handle_list_channels(**kwargs) -> str: error_msg = f"Slack List Channels Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_get_channel_history(**kwargs) -> str: @@ -131,7 +131,7 @@ def handle_get_channel_history(**kwargs) -> str: try: channel = kwargs.get("channel") if not channel: - return json.dumps({"success": False, "error": "channel is required"}) + return json.dumps({"success": False, "error": "channel is required"}, default=str) limit = int(kwargs.get("limit", 100)) headers = _get_slack_headers() @@ -148,7 +148,7 @@ def handle_get_channel_history(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) messages = [{"ts": msg.get("ts"), "text": msg.get("text", ""), "user": msg.get("user", "bot" if msg.get("bot_id") else "unknown")} for msg in data.get("messages", [])] @@ -162,7 +162,7 @@ def handle_get_channel_history(**kwargs) -> str: error_msg = f"Slack Get History Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_search_messages(**kwargs) -> str: @@ -171,7 +171,7 @@ def handle_search_messages(**kwargs) -> str: try: query = kwargs.get("query") if not query: - return json.dumps({"success": False, "error": "query is required"}) + return json.dumps({"success": False, "error": "query is required"}, default=str) limit = int(kwargs.get("limit", 20)) headers = _get_slack_headers() @@ -188,7 +188,7 @@ def handle_search_messages(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) matches = data.get("messages", {}).get("matches", []) results = [{"text": msg.get("text", ""), "user": msg.get("username", msg.get("user", "unknown")), "channel": msg.get("channel", {}).get("name", "unknown"), "permalink": msg.get("permalink")} @@ -203,7 +203,7 @@ def handle_search_messages(**kwargs) -> str: error_msg = f"Slack Search Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_list_users(**kwargs) -> str: @@ -225,7 +225,7 @@ def handle_list_users(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) users = [{"id": user.get("id"), "name": user.get("name"), "real_name": user.get("real_name", ""), "is_bot": user.get("is_bot", False)} for user in data.get("members", []) @@ -240,4 +240,4 @@ def handle_list_users(**kwargs) -> str: error_msg = f"Slack List Users Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) diff --git a/huf/ai/tools/telegram.py b/huf/ai/tools/telegram.py index 9c0ebdb5..46688ac4 100644 --- a/huf/ai/tools/telegram.py +++ b/huf/ai/tools/telegram.py @@ -16,14 +16,14 @@ def handle_send_message(**kwargs) -> str: chat_id = kwargs.get("chat_id") message = kwargs.get("message") if not all([chat_id, message]): - return json.dumps({"success": False, "error": "chat_id and message are required"}) + return json.dumps({"success": False, "error": "chat_id and message are required"}, default=str) # Get token from Integration Settings token = get_credential(service_name, "token") if not token: error_msg = "Telegram bot token not configured" update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) url = f"https://api.telegram.org/bot{token}/sendMessage" payload = { @@ -43,4 +43,4 @@ def handle_send_message(**kwargs) -> str: error_msg = f"Telegram Send Message Error: {str(e)}" frappe.log_error(error_msg, "Telegram Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) From fd7be3c53832a1860141052bc3c84dbc11866bb8 Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Wed, 10 Jun 2026 06:58:06 +0000 Subject: [PATCH 08/12] refactor: implement dynamic field updates and fuzzy search for CRM - Dynamic Updates: Removed the restrictive, hardcoded updatable field arrays in _handle_update_lead and _handle_update_opportunity. Updates now dynamically check the ERPNext DocType metadata to allow any valid, non-structural field to be updated safely, including custom fields. - Fuzzy Search: Updated _handle_get_leads search logic to replace spaces with % wildcards. --- huf/ai/tools/erpnext_crm.py | 57 +++++++++++++++---------------------- 1 file changed, 23 insertions(+), 34 deletions(-) diff --git a/huf/ai/tools/erpnext_crm.py b/huf/ai/tools/erpnext_crm.py index 137f96b9..d86cef51 100644 --- a/huf/ai/tools/erpnext_crm.py +++ b/huf/ai/tools/erpnext_crm.py @@ -42,10 +42,11 @@ def _handle_get_leads(**kwargs) -> str: or_filters = [] if search: + search_term = f"%{search.replace(' ', '%')}%" or_filters = [ - ["Lead", "lead_name", "like", f"%{search}%"], - ["Lead", "company_name", "like", f"%{search}%"], - ["Lead", "email_id", "like", f"%{search}%"], + ["Lead", "lead_name", "like", search_term], + ["Lead", "company_name", "like", search_term], + ["Lead", "email_id", "like", search_term], ] fields = [ @@ -140,23 +141,14 @@ def _handle_update_lead(**kwargs) -> str: return _error(f"Lead {name} not found") doc = frappe.get_doc("Lead", name) - updatable = [ - "status", - "lead_owner", - "email_id", - "mobile_no", - "territory", - "qualification_status", - "company_name", - "phone", - "website", - "industry", - "market_segment", - "type", - ] - for field in updatable: - if field in kwargs: - setattr(doc, field, kwargs[field]) + restricted_fields = {"name", "doctype", "creation", "modified", "modified_by", "owner", "docstatus", "action"} + for field, value in kwargs.items(): + if field in restricted_fields: + continue + if doc.meta.has_field(field): + df = doc.meta.get_field(field) + if df.fieldtype not in ("Table", "Table MultiSelect"): + setattr(doc, field, value) doc.save(ignore_permissions=True) return json.dumps( @@ -262,20 +254,17 @@ def _handle_update_opportunity(**kwargs) -> str: return _error(f"Opportunity {name} not found") doc = frappe.get_doc("Opportunity", name) - updatable = [ - "status", - "opportunity_amount", - "sales_stage", - "probability", - "expected_closing", - "order_lost_reason", - ] - for field in updatable: - if field in kwargs: - if field in ("opportunity_amount", "probability"): - setattr(doc, field, float(kwargs[field])) - else: - setattr(doc, field, kwargs[field]) + restricted_fields = {"name", "doctype", "creation", "modified", "modified_by", "owner", "docstatus", "action"} + for field, value in kwargs.items(): + if field in restricted_fields: + continue + if doc.meta.has_field(field): + df = doc.meta.get_field(field) + if df.fieldtype not in ("Table", "Table MultiSelect"): + if df.fieldtype in ("Currency", "Float", "Percent"): + setattr(doc, field, float(value) if value else 0.0) + else: + setattr(doc, field, value) doc.save(ignore_permissions=True) return json.dumps({"success": True, "results": {"name": doc.name}}, default=str) From 8da2795b5ed88358bb7f147d4df8f53a9e43ea6a Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Wed, 10 Jun 2026 06:58:42 +0000 Subject: [PATCH 09/12] fix: apply fuzzy search wildcards for customer queries --- huf/ai/tools/erpnext.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/huf/ai/tools/erpnext.py b/huf/ai/tools/erpnext.py index fa487319..9c476b4b 100644 --- a/huf/ai/tools/erpnext.py +++ b/huf/ai/tools/erpnext.py @@ -460,10 +460,11 @@ def _handle_get_customers(**kwargs) -> str: or_filters = [] if search: + search_term = f"%{search.replace(' ', '%')}%" or_filters = [ - ["Customer", "name", "like", f"%{search}%"], - ["Customer", "customer_name", "like", f"%{search}%"], - ["Customer", "customer_group", "like", f"%{search}%"], + ["Customer", "name", "like", search_term], + ["Customer", "customer_name", "like", search_term], + ["Customer", "customer_group", "like", search_term], ] fields = [ From ce818891861ce08863f942c82f6b1cba43c596ed Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Wed, 10 Jun 2026 06:59:13 +0000 Subject: [PATCH 10/12] fix: apply fuzzy search wildcards for item queries --- huf/ai/tools/erpnext_inventory.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/huf/ai/tools/erpnext_inventory.py b/huf/ai/tools/erpnext_inventory.py index 91812033..e5a0cf7b 100644 --- a/huf/ai/tools/erpnext_inventory.py +++ b/huf/ai/tools/erpnext_inventory.py @@ -45,10 +45,11 @@ def _handle_get_items(**kwargs) -> str: or_filters = [] if search: + search_term = f"%{search.replace(' ', '%')}%" or_filters = [ - ["Item", "item_code", "like", f"%{search}%"], - ["Item", "item_name", "like", f"%{search}%"], - ["Item", "item_group", "like", f"%{search}%"], + ["Item", "item_code", "like", search_term], + ["Item", "item_name", "like", search_term], + ["Item", "item_group", "like", search_term], ] fields = [ From e2b0c0b76473a27d0d9751b2e6d52e9396d852af Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Wed, 10 Jun 2026 07:02:21 +0000 Subject: [PATCH 11/12] fix: stabilize script report execution and enhance report discovery - Auto-date Resolution: handle_run_report now intercepts fiscal_year arguments and queries the database to auto-inject mandatory from_date, to_date, period_start_date, and period_end_date filters, preventing Frappe backend crashes on financial statements. - Periodicity Fallback: Injected a periodicity: Yearly default fallback to prevent KeyError: None on financial statement generation. - JSON Serialization: Added default=str to json.dumps to prevent crashes when report results return native Python datetime.date objects. - Global Report Search: Added a search parameter to handle_list_reports, allowing the AI to fuzzy-search for report names across all modules simultaneously if it miscategorizes the module. --- huf/ai/tools/_registry.py | 4 ++- huf/ai/tools/erpnext_reports.py | 48 +++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/huf/ai/tools/_registry.py b/huf/ai/tools/_registry.py index cf0eed6d..186a7aa1 100644 --- a/huf/ai/tools/_registry.py +++ b/huf/ai/tools/_registry.py @@ -296,7 +296,7 @@ def _action(choices): ERPNEXT_TOOLS = [{ "tool_name": "erpnext", - "description": "Manage ERPNext transactions and accounting. Actions: list_sales_invoices (customer, status, from_date, to_date, limit), get_sales_invoice (name), create_sales_invoice (customer required, items list [{item_code,qty,rate}], company, posting_date), list_purchase_invoices (supplier, status, from_date, to_date, limit), get_purchase_invoice (name), list_payments (party_type, party, payment_type, from_date, to_date, limit), create_payment (payment_type required [Receive/Pay], party_type required, party required, paid_amount required; mode_of_payment, invoice_name), list_customers (search, customer_group, limit), get_customer (name), list_quotations (party_name, status, from_date, limit), create_quotation (quotation_to required [Customer/Lead], party_name required, items list, transaction_date, valid_till), list_rfqs (status, from_date, limit), get_ledger (account required, from_date, to_date, party_type, party, limit), create_journal_entry (voucher_type, posting_date, company, user_remark, accounts list [{account, debit_in_account_currency, credit_in_account_currency}]).", + "description": "Manage ERPNext transactions and accounting. Actions: list_sales_invoices (customer, status, from_date, to_date, limit), get_sales_invoice (name), create_sales_invoice (customer required, items list [{item_code,qty,rate}], company, posting_date), list_purchase_invoices (supplier, status, from_date, to_date, limit), get_purchase_invoice (name), list_payments (party_type, party, payment_type, from_date, to_date, limit), create_payment (payment_type required [Receive/Pay], party_type required, party required, paid_amount required; mode_of_payment, invoice_name, source_exchange_rate, target_exchange_rate), list_customers (search, customer_group, limit), get_customer (name), list_quotations (party_name, status, from_date, limit), create_quotation (quotation_to required [Customer/Lead], party_name required, items list, transaction_date, valid_till), list_rfqs (status, from_date, limit), get_ledger (account required, from_date, to_date, party_type, party, limit), create_journal_entry (voucher_type, posting_date, company, user_remark, accounts list [{account, debit_in_account_currency, credit_in_account_currency}]).", "function_path": "huf.ai.tools.erpnext.handle_action", "category": "ERPNext Tools", "parameters": [ @@ -310,6 +310,8 @@ def _action(choices): _p("paid_amount", type="number", description="Payment amount"), _p("invoice_name", description="Invoice to link payment to"), _p("mode_of_payment", description="Mode of payment"), + _p("source_exchange_rate", type="number", description="Source exchange rate (if different currency)"), + _p("target_exchange_rate", type="number", description="Target exchange rate (if different currency)"), _p("account", description="Account name (for get_ledger)"), _p("status", description="Document status filter"), _p("from_date", description="Start date (YYYY-MM-DD)"), diff --git a/huf/ai/tools/erpnext_reports.py b/huf/ai/tools/erpnext_reports.py index 7bde2997..483b777e 100644 --- a/huf/ai/tools/erpnext_reports.py +++ b/huf/ai/tools/erpnext_reports.py @@ -98,6 +98,35 @@ def handle_run_report(**kwargs) -> str: if default_company: filters["company"] = default_company + # Auto-resolve dates from fiscal year if missing + if not filters.get("from_date") and not filters.get("to_date"): + fiscal_year = filters.get("fiscal_year") + if not fiscal_year: + fiscal_year = frappe.defaults.get_user_default("Fiscal Year") or frappe.db.get_single_value("Global Defaults", "current_fiscal_year") + + if fiscal_year: + try: + fy_doc = None + if frappe.db.exists("Fiscal Year", fiscal_year): + fy_doc = frappe.get_doc("Fiscal Year", fiscal_year) + else: + fy_list = frappe.get_all("Fiscal Year", filters={"name": ("like", f"%{fiscal_year}%")}, limit=1) + if fy_list: + fy_doc = frappe.get_doc("Fiscal Year", fy_list[0].name) + + if fy_doc: + filters["from_date"] = fy_doc.year_start_date + filters["to_date"] = fy_doc.year_end_date + filters["period_start_date"] = fy_doc.year_start_date + filters["period_end_date"] = fy_doc.year_end_date + filters["fiscal_year"] = fy_doc.name + except Exception: + pass + + # Set default periodicity for financial statements if missing + if not filters.get("periodicity"): + filters["periodicity"] = "Yearly" + from frappe.desk.query_report import run as run_report result = run_report(report_name=report_name, filters=filters, ignore_prepared_report=True) return json.dumps({ @@ -106,20 +135,35 @@ def handle_run_report(**kwargs) -> str: "columns": result.get("columns", []), "results": result.get("result", []), "count": len(result.get("result", [])), - }) + }, default=str) except Exception as e: frappe.log_error(f"ERPNext Report Error [{report_name}]: {e}", "ERPNext Reports Tool") return _error(str(e)) def handle_list_reports(**kwargs) -> str: - """List available reports, optionally filtered by module.""" + """List available reports, optionally filtered by module or search keyword.""" module = kwargs.get("module", "").strip() + search = kwargs.get("search", "").strip().lower() + if module: # case-insensitive match matched = {k: v for k, v in REPORT_CATALOGUE.items() if k.lower() == module.lower()} + if search: + for k in matched: + matched[k] = [r for r in matched[k] if search in r.lower()] + if not matched: available = list(REPORT_CATALOGUE.keys()) return json.dumps({"success": False, "error": f"Module '{module}' not found. Available: {available}"}, default=str) return json.dumps({"success": True, "results": matched}, default=str) + + if search: + matched = {} + for mod, reports in REPORT_CATALOGUE.items(): + filtered = [r for r in reports if search in r.lower()] + if filtered: + matched[mod] = filtered + return json.dumps({"success": True, "results": matched}, default=str) + return json.dumps({"success": True, "results": REPORT_CATALOGUE}, default=str) From be5595665ef440a1ee539f24e8550dc6ede15301 Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Wed, 10 Jun 2026 09:33:42 +0000 Subject: [PATCH 12/12] fix: handle slugified channel names and add fuzzy search for Raven --- huf/ai/tools/raven.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/huf/ai/tools/raven.py b/huf/ai/tools/raven.py index c6aa8600..3a9d47e6 100644 --- a/huf/ai/tools/raven.py +++ b/huf/ai/tools/raven.py @@ -25,6 +25,19 @@ def _resolve_channel_id(channel_id=None, channel_name=None): cid = frappe.db.get_value("Raven Channel", {"channel_name": channel_name}, "name") if cid: return cid + + # Fallback: Raven often slugifies channel names (e.g. "Project Alpha" -> "project-alpha") + slugified = channel_name.lower().replace(" ", "-") + cid = frappe.db.get_value("Raven Channel", {"channel_name": slugified}, "name") + if cid: + return cid + + # Final fallback: fuzzy like match + search_term = f"%{channel_name.replace(' ', '%')}%" + channels = frappe.get_all("Raven Channel", filters={"channel_name": ("like", search_term)}, limit=1) + if channels: + return channels[0].name + return None