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 import json
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Any, Awaitable, Callable, Optional 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 sqlalchemy.ext.asyncio import AsyncSession
from app.api.tld_prices import get_trending_tlds from app.api.tld_prices import get_trending_tlds
from app.models.auction import DomainAuction from app.models.auction import DomainAuction
from app.models.domain import Domain from app.models.domain import Domain
from app.models.listing import DomainListing, ListingStatus 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.subscription import Subscription, SubscriptionTier
from app.models.user import User 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.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 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]: 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) 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) 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, min_tier=SubscriptionTier.TRADER,
handler=tool_get_dashboard_summary, 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( ToolDef(
name="list_watchlist", name="list_watchlist",
description="List user's watchlist domains (monitored domains).", description="List user's watchlist domains (monitored domains).",
@ -292,6 +768,70 @@ def get_tool_defs() -> list[ToolDef]:
min_tier=SubscriptionTier.TRADER, min_tier=SubscriptionTier.TRADER,
handler=tool_list_watchlist, 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( ToolDef(
name="analyze_domain", name="analyze_domain",
description="Run Pounce domain analysis (Authority/Market/Risk/Value) for a given 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, min_tier=SubscriptionTier.TRADER,
handler=tool_get_drops, 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] p = (path or "").split("?")[0]
if p.startswith("/terminal/hunt"): 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"): if p.startswith("/terminal/market"):
return ["get_subscription", "market_feed", "analyze_domain"] return ["get_subscription", "market_feed", "analyze_domain"]
if p.startswith("/terminal/watchlist"): if p.startswith("/terminal/watchlist"):
return ["get_subscription", "list_watchlist", "analyze_domain"] 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 # default: allow a safe minimal set
return ["get_subscription", "get_dashboard_summary", "analyze_domain"] return ["get_subscription", "get_dashboard_summary", "analyze_domain"]