LLM Agent tools: cover all terminal pages (hunt/portfolio/sniper/listing/inbox/yield/etc.)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled

This commit is contained in:
2025-12-17 14:35:53 +01:00
parent 8f6e13ffcf
commit 01d6d24e59

View File

@ -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"]