diff --git a/backend/app/services/llm_tools.py b/backend/app/services/llm_tools.py index 5eb0d86..4ace105 100644 --- a/backend/app/services/llm_tools.py +++ b/backend/app/services/llm_tools.py @@ -2,18 +2,28 @@ from __future__ import annotations import json from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal from typing import Any, Awaitable, Callable, Optional -from sqlalchemy import and_, func, select +from sqlalchemy import and_, case, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.tld_prices import get_trending_tlds from app.models.auction import DomainAuction from app.models.domain import Domain from app.models.listing import DomainListing, ListingStatus +from app.models.listing import ListingInquiry, ListingInquiryMessage +from app.models.portfolio import PortfolioDomain +from app.models.sniper_alert import SniperAlert from app.models.subscription import Subscription, SubscriptionTier from app.models.user import User +from app.models.yield_domain import YieldDomain, YieldTransaction from app.services.analyze.service import get_domain_analysis +from app.services.domain_checker import domain_checker +from app.services.hunt.brandables import check_domains, generate_cvcvc, generate_cvccv, generate_human +from app.services.hunt.trends import fetch_google_trends_daily_rss +from app.services.hunt.typos import generate_typos from app.services.zone_file import get_dropped_domains @@ -144,6 +154,413 @@ async def tool_get_dashboard_summary(db: AsyncSession, user: User, args: dict[st } +async def tool_hunt_trends(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: + geo = (args.get("geo") or "US").strip().upper() + if len(geo) != 2: + geo = "US" + items_raw = await fetch_google_trends_daily_rss(geo=geo) + return { + "geo": geo, + "items": [ + { + "title": i["title"], + "approx_traffic": i.get("approx_traffic"), + "published_at": i.get("published_at"), + "link": i.get("link"), + } + for i in items_raw[:50] + ], + "timestamp": datetime.utcnow().isoformat(), + } + + +async def tool_hunt_brandables(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: + pattern = (args.get("pattern") or "cvcvc").strip().lower() + if pattern not in ("cvcvc", "cvccv", "human"): + pattern = "cvcvc" + + tlds = args.get("tlds") or ["com"] + if not isinstance(tlds, list): + tlds = ["com"] + tlds = [str(t).lower().lstrip(".") for t in tlds if str(t).strip()] + if not tlds: + tlds = ["com"] + + max_checks = _clamp_int(args.get("max_checks"), lo=10, hi=80, default=40) + limit = _clamp_int(args.get("limit"), lo=1, hi=25, default=15) + + candidates: list[str] = [] + for _ in range(max_checks): + if pattern == "cvcvc": + sld = generate_cvcvc() + elif pattern == "cvccv": + sld = generate_cvccv() + else: + sld = generate_human() + for t in tlds: + candidates.append(f"{sld}.{t}") + + checked = await check_domains(candidates, concurrency=30) + available = [c for c in checked if c.status == "available"] + seen = set() + out = [] + for c in available: + if c.domain in seen: + continue + seen.add(c.domain) + out.append({"domain": c.domain, "status": c.status, "is_available": bool(c.is_available)}) + if len(out) >= limit: + break + + return {"pattern": pattern, "tlds": tlds, "items": out, "timestamp": datetime.utcnow().isoformat()} + + +async def tool_hunt_typos(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: + brand = (args.get("brand") or "").strip() + if not brand or len(brand) < 2: + return {"error": "Missing brand"} + limit = _clamp_int(args.get("limit"), lo=1, hi=30, default=15) + tlds = args.get("tlds") or ["com"] + if not isinstance(tlds, list): + tlds = ["com"] + tlds = [str(t).lower().lstrip(".") for t in tlds if str(t).strip()] + if not tlds: + tlds = ["com"] + + # generate_typos returns SLD strings; we format domains for UX + slugs = generate_typos(brand, limit=limit) + items = [] + for sld in slugs: + for t in tlds[:3]: + items.append(f"{sld}.{t}") + if len(items) >= limit: + break + if len(items) >= limit: + break + + return {"brand": brand, "items": items, "timestamp": datetime.utcnow().isoformat()} + + +async def tool_keyword_availability(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: + keyword = (args.get("keyword") or "").strip().lower() + if not keyword: + return {"error": "Missing keyword"} + tlds = args.get("tlds") or ["com"] + if not isinstance(tlds, list): + tlds = ["com"] + tlds = [str(t).lower().lstrip(".") for t in tlds if str(t).strip()] + if not tlds: + tlds = ["com"] + limit = _clamp_int(args.get("limit"), lo=1, hi=25, default=10) + + candidates = [f"{keyword}.{t}" for t in tlds][:limit] + results = [] + for d in candidates: + r = await domain_checker.check_domain(d) + results.append({"domain": d, "status": r.status, "is_available": bool(getattr(r, "is_available", False))}) + return {"keyword": keyword, "items": results, "timestamp": datetime.utcnow().isoformat()} + + +async def tool_portfolio_summary(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: + rows = (await db.execute(select(PortfolioDomain).where(PortfolioDomain.user_id == user.id))).scalars().all() + total = len(rows) + active = sum(1 for d in rows if not d.is_sold and (d.status or "active") != "expired") + sold = sum(1 for d in rows if bool(d.is_sold)) + total_cost = float(sum(Decimal(str(d.purchase_price or 0)) for d in rows)) + total_value = float(sum(Decimal(str(d.estimated_value or 0)) for d in rows if not d.is_sold)) + roi = None + if total_cost > 0: + roi = round(((total_value - total_cost) / total_cost) * 100, 2) + return { + "total_domains": total, + "active_domains": active, + "sold_domains": sold, + "total_cost": round(total_cost, 2), + "total_estimated_value": round(total_value, 2), + "overall_roi_percent": roi, + "timestamp": datetime.utcnow().isoformat(), + } + + +async def tool_list_portfolio(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: + limit = _clamp_int(args.get("limit"), lo=1, hi=50, default=25) + status = (args.get("status") or "").strip().lower() or None + q = select(PortfolioDomain).where(PortfolioDomain.user_id == user.id) + if status: + q = q.where(PortfolioDomain.status == status) + q = q.order_by(PortfolioDomain.created_at.desc()).limit(limit) + rows = (await db.execute(q)).scalars().all() + return { + "items": [ + { + "id": d.id, + "domain": d.domain, + "status": d.status, + "purchase_price": d.purchase_price, + "estimated_value": d.estimated_value, + "roi": d.roi, + "renewal_date": d.renewal_date.isoformat() if d.renewal_date else None, + "is_dns_verified": bool(getattr(d, "is_dns_verified", False) or False), + } + for d in rows + ], + "count": len(rows), + "timestamp": datetime.utcnow().isoformat(), + } + + +async def tool_list_sniper_alerts(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: + limit = _clamp_int(args.get("limit"), lo=1, hi=50, default=25) + rows = ( + await db.execute( + select(SniperAlert) + .where(SniperAlert.user_id == user.id) + .order_by(SniperAlert.created_at.desc()) + .limit(limit) + ) + ).scalars().all() + return { + "items": [ + { + "id": a.id, + "name": a.name, + "description": a.description, + "is_active": bool(a.is_active), + "tlds": a.tlds, + "keywords": a.keywords, + "exclude_keywords": a.exclude_keywords, + "max_price": a.max_price, + "ending_within_hours": a.ending_within_hours, + "notify_email": bool(a.notify_email), + "notify_sms": bool(a.notify_sms), + "created_at": a.created_at.isoformat() if a.created_at else None, + } + for a in rows + ], + "count": len(rows), + "timestamp": datetime.utcnow().isoformat(), + } + + +async def tool_list_my_listings(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: + limit = _clamp_int(args.get("limit"), lo=1, hi=50, default=25) + rows = ( + await db.execute( + select(DomainListing) + .where(DomainListing.user_id == user.id) + .order_by(DomainListing.updated_at.desc()) + .limit(limit) + ) + ).scalars().all() + return { + "items": [ + { + "id": l.id, + "domain": l.domain, + "status": l.status, + "asking_price": l.asking_price, + "min_offer": l.min_offer, + "currency": l.currency, + "slug": l.slug, + "verification_status": l.verification_status, + "view_count": l.view_count, + "inquiry_count": l.inquiry_count, + "updated_at": l.updated_at.isoformat() if l.updated_at else None, + } + for l in rows + ], + "count": len(rows), + "timestamp": datetime.utcnow().isoformat(), + } + + +async def tool_get_inbox_counts(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: + buyer_inqs = (await db.execute(select(ListingInquiry.id).where(ListingInquiry.buyer_user_id == user.id))).scalars().all() + buyer_unread = 0 + for inq_id in list(buyer_inqs)[:200]: + msg = ( + await db.execute( + select(ListingInquiryMessage) + .where(ListingInquiryMessage.inquiry_id == inq_id) + .order_by(ListingInquiryMessage.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if msg and msg.sender_user_id != user.id: + buyer_unread += 1 + + listing_ids = (await db.execute(select(DomainListing.id).where(DomainListing.user_id == user.id))).scalars().all() + seller_unread = 0 + if listing_ids: + new_count = ( + await db.execute( + select(func.count(ListingInquiry.id)).where( + and_(ListingInquiry.listing_id.in_(listing_ids), ListingInquiry.status == "new") + ) + ) + ).scalar() or 0 + seller_unread += int(new_count) + + inqs = ( + await db.execute( + select(ListingInquiry.id, ListingInquiry.status) + .where( + and_( + ListingInquiry.listing_id.in_(listing_ids), + ListingInquiry.status.notin_(["closed", "spam"]), + ) + ) + .order_by(ListingInquiry.created_at.desc()) + .limit(200) + ) + ).all() + for inq_id, st in inqs: + if st == "new": + continue + msg = ( + await db.execute( + select(ListingInquiryMessage) + .where(ListingInquiryMessage.inquiry_id == inq_id) + .order_by(ListingInquiryMessage.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if msg and msg.sender_user_id != user.id: + seller_unread += 1 + + return {"buyer_unread": buyer_unread, "seller_unread": seller_unread, "total_unread": buyer_unread + seller_unread} + + +async def tool_get_seller_inbox(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: + limit = _clamp_int(args.get("limit"), lo=1, hi=50, default=25) + status_filter = (args.get("status") or "all").strip().lower() + + listings = (await db.execute(select(DomainListing).where(DomainListing.user_id == user.id))).scalars().all() + listing_map = {l.id: l for l in listings} + if not listing_map: + return {"inquiries": [], "total": 0, "unread": 0} + + q = ( + select(ListingInquiry) + .where(ListingInquiry.listing_id.in_(list(listing_map.keys()))) + .order_by(ListingInquiry.created_at.desc()) + ) + if status_filter and status_filter != "all": + q = q.where(ListingInquiry.status == status_filter) + inqs = (await db.execute(q.limit(limit))).scalars().all() + + unread = sum(1 for i in inqs if i.status == "new" or not i.read_at) + items = [] + for inq in inqs: + listing = listing_map.get(inq.listing_id) + if not listing: + continue + latest_msg = ( + await db.execute( + select(ListingInquiryMessage) + .where(ListingInquiryMessage.inquiry_id == inq.id) + .order_by(ListingInquiryMessage.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + items.append( + { + "id": inq.id, + "domain": listing.domain, + "listing_id": listing.id, + "slug": listing.slug, + "status": inq.status, + "buyer_name": inq.name, + "offer_amount": inq.offer_amount, + "created_at": inq.created_at.isoformat() if inq.created_at else None, + "has_unread_reply": bool(latest_msg and latest_msg.sender_user_id != user.id and inq.status not in ["closed", "spam"]), + "last_message_preview": ( + (latest_msg.body[:100] + "..." if len(latest_msg.body) > 100 else latest_msg.body) + if latest_msg + else ((inq.message or "")[:100]) + ), + "last_message_at": latest_msg.created_at.isoformat() if latest_msg and latest_msg.created_at else (inq.created_at.isoformat() if inq.created_at else None), + } + ) + + return {"inquiries": items, "total": len(items), "unread": unread, "timestamp": datetime.utcnow().isoformat()} + + +async def tool_yield_dashboard(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: + now = datetime.utcnow() + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + domains = ( + await db.execute( + select(YieldDomain) + .where(YieldDomain.user_id == user.id) + .order_by(YieldDomain.total_revenue.desc()) + ) + ).scalars().all() + domain_ids = [d.id for d in domains] + + monthly_revenue = Decimal("0") + monthly_clicks = 0 + monthly_conversions = 0 + if domain_ids: + monthly_stats = ( + await db.execute( + select( + func.coalesce( + func.sum( + case( + (YieldTransaction.status.in_(["confirmed", "paid"]), YieldTransaction.net_amount), + else_=0, + ) + ), + 0, + ).label("revenue"), + func.sum(case((YieldTransaction.event_type == "click", 1), else_=0)).label("clicks"), + func.sum( + case( + ( + and_( + YieldTransaction.event_type.in_(["lead", "sale"]), + YieldTransaction.status.in_(["confirmed", "paid"]), + ), + 1, + ), + else_=0, + ) + ).label("conversions"), + ).where(YieldTransaction.yield_domain_id.in_(domain_ids), YieldTransaction.created_at >= month_start) + ) + ).first() + if monthly_stats: + monthly_revenue = monthly_stats.revenue or Decimal("0") + monthly_clicks = int(monthly_stats.clicks or 0) + monthly_conversions = int(monthly_stats.conversions or 0) + + return { + "stats": { + "active_domains": sum(1 for d in domains if d.status == "active"), + "total_domains": len(domains), + "monthly_revenue": float(monthly_revenue), + "monthly_clicks": monthly_clicks, + "monthly_conversions": monthly_conversions, + }, + "top_domains": [ + { + "id": d.id, + "domain": d.domain, + "status": d.status, + "dns_verified": bool(d.dns_verified), + "total_revenue": float(d.total_revenue or 0), + "total_clicks": int(d.total_clicks or 0), + "total_conversions": int(d.total_conversions or 0), + "detected_intent": d.detected_intent, + } + for d in domains[:10] + ], + "timestamp": now.isoformat(), + } + + async def tool_list_watchlist(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: page = _clamp_int(args.get("page"), lo=1, hi=50, default=1) per_page = _clamp_int(args.get("per_page"), lo=1, hi=50, default=20) @@ -278,6 +695,65 @@ def get_tool_defs() -> list[ToolDef]: min_tier=SubscriptionTier.TRADER, handler=tool_get_dashboard_summary, ), + ToolDef( + name="hunt_trends", + description="Get Google Trends daily RSS items (Hunt > Trends).", + json_schema={ + "type": "object", + "properties": {"geo": {"type": "string", "minLength": 2, "maxLength": 2}}, + "additionalProperties": False, + }, + min_tier=SubscriptionTier.TRADER, + handler=tool_hunt_trends, + ), + ToolDef( + name="hunt_brandables", + description="Generate brandable domains and check availability (Hunt > Forge).", + json_schema={ + "type": "object", + "properties": { + "pattern": {"type": "string", "enum": ["cvcvc", "cvccv", "human"]}, + "tlds": {"type": "array", "items": {"type": "string"}}, + "max_checks": {"type": "integer", "minimum": 10, "maximum": 80}, + "limit": {"type": "integer", "minimum": 1, "maximum": 25}, + }, + "additionalProperties": False, + }, + min_tier=SubscriptionTier.TRADER, + handler=tool_hunt_brandables, + ), + ToolDef( + name="hunt_typos", + description="Generate typo candidates for a brand (Hunt > Typos).", + json_schema={ + "type": "object", + "properties": { + "brand": {"type": "string"}, + "tlds": {"type": "array", "items": {"type": "string"}}, + "limit": {"type": "integer", "minimum": 1, "maximum": 30}, + }, + "required": ["brand"], + "additionalProperties": False, + }, + min_tier=SubscriptionTier.TRADER, + handler=tool_hunt_typos, + ), + ToolDef( + name="keyword_availability", + description="Check availability for keyword + TLD list (Hunt > Search).", + json_schema={ + "type": "object", + "properties": { + "keyword": {"type": "string"}, + "tlds": {"type": "array", "items": {"type": "string"}}, + "limit": {"type": "integer", "minimum": 1, "maximum": 25}, + }, + "required": ["keyword"], + "additionalProperties": False, + }, + min_tier=SubscriptionTier.TRADER, + handler=tool_keyword_availability, + ), ToolDef( name="list_watchlist", description="List user's watchlist domains (monitored domains).", @@ -292,6 +768,70 @@ def get_tool_defs() -> list[ToolDef]: min_tier=SubscriptionTier.TRADER, handler=tool_list_watchlist, ), + ToolDef( + name="portfolio_summary", + description="Get portfolio summary (counts, total cost/value, ROI).", + json_schema={"type": "object", "properties": {}, "additionalProperties": False}, + min_tier=SubscriptionTier.TRADER, + handler=tool_portfolio_summary, + ), + ToolDef( + name="list_portfolio", + description="List portfolio domains (owned domains).", + json_schema={ + "type": "object", + "properties": { + "status": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 50}, + }, + "additionalProperties": False, + }, + min_tier=SubscriptionTier.TRADER, + handler=tool_list_portfolio, + ), + ToolDef( + name="list_sniper_alerts", + description="List sniper alerts (user-defined filters).", + json_schema={ + "type": "object", + "properties": {"limit": {"type": "integer", "minimum": 1, "maximum": 50}}, + "additionalProperties": False, + }, + min_tier=SubscriptionTier.TRADER, + handler=tool_list_sniper_alerts, + ), + ToolDef( + name="list_my_listings", + description="List seller's Pounce Direct listings (For Sale).", + json_schema={ + "type": "object", + "properties": {"limit": {"type": "integer", "minimum": 1, "maximum": 50}}, + "additionalProperties": False, + }, + min_tier=SubscriptionTier.TRADER, + handler=tool_list_my_listings, + ), + ToolDef( + name="get_inbox_counts", + description="Get unified buyer/seller unread counts for inbox badge.", + json_schema={"type": "object", "properties": {}, "additionalProperties": False}, + min_tier=SubscriptionTier.TRADER, + handler=tool_get_inbox_counts, + ), + ToolDef( + name="get_seller_inbox", + description="Seller inbox threads across all listings (preview list).", + json_schema={ + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["all", "new", "read", "replied", "closed", "spam"]}, + "limit": {"type": "integer", "minimum": 1, "maximum": 50}, + }, + "additionalProperties": False, + }, + min_tier=SubscriptionTier.TRADER, + handler=tool_get_seller_inbox, + ), ToolDef( name="analyze_domain", description="Run Pounce domain analysis (Authority/Market/Risk/Value) for a given domain.", @@ -347,6 +887,13 @@ def get_tool_defs() -> list[ToolDef]: min_tier=SubscriptionTier.TRADER, handler=tool_get_drops, ), + ToolDef( + name="yield_dashboard", + description="Get Yield dashboard stats and top earning domains.", + json_schema={"type": "object", "properties": {}, "additionalProperties": False}, + min_tier=SubscriptionTier.TRADER, + handler=tool_yield_dashboard, + ), ] @@ -357,11 +904,38 @@ def tools_for_path(path: str) -> list[str]: """ p = (path or "").split("?")[0] if p.startswith("/terminal/hunt"): - return ["get_subscription", "get_dashboard_summary", "market_feed", "get_drops", "analyze_domain", "list_watchlist"] + return [ + "get_subscription", + "get_dashboard_summary", + "market_feed", + "get_drops", + "hunt_trends", + "hunt_brandables", + "hunt_typos", + "keyword_availability", + "analyze_domain", + "list_watchlist", + ] if p.startswith("/terminal/market"): return ["get_subscription", "market_feed", "analyze_domain"] if p.startswith("/terminal/watchlist"): return ["get_subscription", "list_watchlist", "analyze_domain"] + if p.startswith("/terminal/portfolio"): + return ["get_subscription", "portfolio_summary", "list_portfolio", "analyze_domain"] + if p.startswith("/terminal/sniper"): + return ["get_subscription", "list_sniper_alerts", "market_feed", "analyze_domain"] + if p.startswith("/terminal/listing"): + return ["get_subscription", "list_my_listings", "get_seller_inbox"] + if p.startswith("/terminal/inbox"): + return ["get_subscription", "get_inbox_counts", "get_seller_inbox"] + if p.startswith("/terminal/yield"): + return ["get_subscription", "yield_dashboard"] + if p.startswith("/terminal/intel"): + return ["get_subscription", "analyze_domain", "market_feed", "get_drops"] + if p.startswith("/terminal/settings"): + return ["get_subscription"] + if p.startswith("/terminal/welcome"): + return ["get_subscription", "get_dashboard_summary"] # default: allow a safe minimal set return ["get_subscription", "get_dashboard_summary", "analyze_domain"]