pounce/backend/app/services/hunter_companion.py
Yves Gugger e75c9bc9ef
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
Hunter Companion v4: Code-First Architecture - no LLM for routing, pure pattern matching
2025-12-17 15:22:34 +01:00

524 lines
16 KiB
Python

"""
Hunter Companion - Code-First Architecture
This module handles the Hunter Companion chat WITHOUT relying on LLM for intent detection.
All routing is done via code (regex, keywords). LLM is only used as a last resort.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.services.llm_tools import execute_tool
class Intent(Enum):
GREETING = "greeting"
HELP = "help"
ANALYZE_DOMAIN = "analyze_domain"
WATCHLIST = "watchlist"
PORTFOLIO = "portfolio"
AUCTIONS = "auctions"
DROPS = "drops"
LISTINGS = "listings"
INBOX = "inbox"
SNIPER = "sniper"
YIELD = "yield"
UNCLEAR = "unclear"
@dataclass
class DetectedIntent:
intent: Intent
params: dict[str, Any]
confidence: float
# ============================================================================
# INTENT DETECTION (Pure Code - No LLM)
# ============================================================================
def detect_intent(text: str) -> DetectedIntent:
"""Detect user intent using pattern matching. No LLM involved."""
t = (text or "").strip().lower()
# 1. Greeting
if _is_greeting(t):
return DetectedIntent(Intent.GREETING, {}, 1.0)
# 2. Help / Capabilities
if _is_help(t):
return DetectedIntent(Intent.HELP, {}, 1.0)
# 3. Domain Analysis (detect domain in text)
domain = _extract_domain(text)
if domain:
return DetectedIntent(Intent.ANALYZE_DOMAIN, {"domain": domain}, 0.95)
# 4. Watchlist queries
if _matches_keywords(t, ["watchlist", "tracking", "monitored", "watching"]):
return DetectedIntent(Intent.WATCHLIST, {}, 0.9)
# 5. Portfolio queries
if _matches_keywords(t, ["portfolio", "my domains", "owned", "roi", "investment", "value"]):
return DetectedIntent(Intent.PORTFOLIO, {}, 0.9)
# 6. Auction queries
if _matches_keywords(t, ["auction", "ending", "deals", "bid", "expir"]):
hours = _extract_hours(t)
return DetectedIntent(Intent.AUCTIONS, {"hours": hours}, 0.9)
# 7. Drops
if _matches_keywords(t, ["drop", "dropped", "zone file", "expired"]):
return DetectedIntent(Intent.DROPS, {}, 0.9)
# 8. Listings / For Sale
if _matches_keywords(t, ["listing", "for sale", "selling", "leads"]):
return DetectedIntent(Intent.LISTINGS, {}, 0.9)
# 9. Inbox
if _matches_keywords(t, ["inbox", "message", "unread", "conversation"]):
return DetectedIntent(Intent.INBOX, {}, 0.9)
# 10. Sniper alerts
if _matches_keywords(t, ["sniper", "alert", "notification", "filter"]):
return DetectedIntent(Intent.SNIPER, {}, 0.9)
# 11. Yield
if _matches_keywords(t, ["yield", "earning", "revenue", "monetiz"]):
return DetectedIntent(Intent.YIELD, {}, 0.9)
# Fallback: unclear intent
return DetectedIntent(Intent.UNCLEAR, {"original": text}, 0.3)
def _is_greeting(t: str) -> bool:
greetings = {"hi", "hello", "hey", "yo", "sup", "hola", "hallo", "moin", "servus"}
clean = re.sub(r'[!?.,]', '', t).strip()
return clean in greetings or (len(clean) <= 10 and any(g in clean for g in greetings))
def _is_help(t: str) -> bool:
patterns = [
"what can you do",
"what do you do",
"help",
"how can you help",
"capabilities",
"features",
"was kannst du",
"hilfe",
]
return any(p in t for p in patterns)
def _matches_keywords(t: str, keywords: list[str]) -> bool:
return any(kw in t for kw in keywords)
def _extract_domain(text: str) -> Optional[str]:
"""Extract a domain from text using regex."""
# Match patterns like: example.com, sub.example.co.uk, etc.
pattern = r'\b([a-zA-Z0-9][-a-zA-Z0-9]*\.)+[a-zA-Z]{2,}\b'
matches = re.findall(pattern, text)
if not matches:
return None
# Reconstruct full domain from the match
full_match = re.search(pattern, text)
if full_match:
domain = full_match.group(0).lower()
# Filter out common non-domains
if domain not in {"example.com", "test.com", "domain.com", "e.g.", "i.e."}:
return domain
return None
def _extract_hours(t: str) -> int:
"""Extract hours from text like '2 hours' or 'next 4h'."""
match = re.search(r'(\d+)\s*(?:hour|h\b|hr)', t)
if match:
return min(int(match.group(1)), 168) # Max 1 week
return 24 # Default
# ============================================================================
# RESPONSE HANDLERS (No LLM - Template Based)
# ============================================================================
RESPONSES = {
Intent.GREETING: """Hey! I'm your Hunter Companion.
I can help you with:
• Analyze any domain - just type it
• Show auctions ending soon
• Check your watchlist or portfolio
• Find recently dropped domains
What would you like to do?""",
Intent.HELP: """Here's what I can do:
• Domain Analysis - Type any domain (e.g., "startup.io")
• Auctions - "show auctions" or "ending soon"
• Watchlist - "show my watchlist"
• Portfolio - "portfolio summary" or "my ROI"
• Drops - "show drops" or "dropped domains"
• Listings - "my listings" or "leads"
Just ask naturally or type a domain to analyze!""",
Intent.UNCLEAR: """I'm not sure what you're looking for.
Try one of these:
• Type a domain to analyze (e.g., "crypto.io")
"show auctions ending soon"
"my watchlist"
"portfolio summary"
What would you like to do?""",
}
async def handle_intent(
db: AsyncSession,
user: User,
detected: DetectedIntent,
path: str,
) -> str:
"""Handle the detected intent and return a response."""
# Static responses
if detected.intent in RESPONSES:
return RESPONSES[detected.intent]
# Dynamic responses (tool-based)
try:
if detected.intent == Intent.ANALYZE_DOMAIN:
return await _handle_analyze(db, user, detected.params.get("domain", ""), path)
if detected.intent == Intent.WATCHLIST:
return await _handle_watchlist(db, user, path)
if detected.intent == Intent.PORTFOLIO:
return await _handle_portfolio(db, user, path)
if detected.intent == Intent.AUCTIONS:
return await _handle_auctions(db, user, detected.params.get("hours", 24), path)
if detected.intent == Intent.DROPS:
return await _handle_drops(db, user, path)
if detected.intent == Intent.LISTINGS:
return await _handle_listings(db, user, path)
if detected.intent == Intent.INBOX:
return await _handle_inbox(db, user, path)
if detected.intent == Intent.SNIPER:
return await _handle_sniper(db, user, path)
if detected.intent == Intent.YIELD:
return await _handle_yield(db, user, path)
except Exception as e:
return f"Sorry, something went wrong: {str(e)}"
return RESPONSES[Intent.UNCLEAR]
async def _handle_analyze(db: AsyncSession, user: User, domain: str, path: str) -> str:
"""Handle domain analysis."""
if not domain:
return "Please provide a domain to analyze."
result = await execute_tool(db, user, "analyze_domain", {"domain": domain}, path=path)
if "error" in result:
return f"Could not analyze {domain}: {result['error']}"
# Format the analysis result
score = result.get("pounce_score", 0)
# Determine recommendation
if score >= 70:
rec = "BUY"
rec_emoji = ""
elif score >= 50:
rec = "CONSIDER"
rec_emoji = "~"
else:
rec = "SKIP"
rec_emoji = ""
lines = [
f"Analysis: {domain}",
f"",
f"Pounce Score: {score}/100",
f"Recommendation: {rec_emoji} {rec}",
]
# Availability
avail = result.get("availability", {})
if avail:
status = "Available" if avail.get("is_available") else "Taken"
lines.append(f"Status: {status}")
# Value estimate
value = result.get("value", {})
if value and value.get("estimated_value"):
lines.append(f"Est. Value: ${value['estimated_value']:,.0f}")
# Risk
risk = result.get("risk", {})
if risk and risk.get("risk_level"):
lines.append(f"Risk: {risk['risk_level']}")
# Radio test
radio = result.get("radio_test", {})
if radio and radio.get("score") is not None:
lines.append(f"Radio Test: {radio['score']}/100")
return "\n".join(lines)
async def _handle_watchlist(db: AsyncSession, user: User, path: str) -> str:
"""Handle watchlist query."""
result = await execute_tool(db, user, "list_watchlist", {"per_page": 10}, path=path)
if "error" in result:
return f"Could not load watchlist: {result['error']}"
domains = result.get("domains", [])
total = result.get("total", 0)
if not domains:
return "Your watchlist is empty.\n\nAdd domains from the Hunt page to start tracking."
lines = [f"Your Watchlist ({total} domains)", ""]
for d in domains[:10]:
status = "" if d.get("is_available") else ""
lines.append(f"{status} {d.get('name', 'unknown')}")
if total > 10:
lines.append(f"\n... and {total - 10} more")
return "\n".join(lines)
async def _handle_portfolio(db: AsyncSession, user: User, path: str) -> str:
"""Handle portfolio query."""
result = await execute_tool(db, user, "portfolio_summary", {}, path=path)
if "error" in result:
return f"Could not load portfolio: {result['error']}"
total = result.get("total_domains", 0)
if total == 0:
return "Your portfolio is empty.\n\nAdd domains you own to track ROI and renewals."
lines = [
f"Portfolio Summary",
"",
f"Total Domains: {total}",
f"Active: {result.get('active_domains', 0)}",
f"Sold: {result.get('sold_domains', 0)}",
"",
f"Total Cost: ${result.get('total_cost', 0):,.0f}",
f"Est. Value: ${result.get('total_estimated_value', 0):,.0f}",
]
roi = result.get("overall_roi_percent")
if roi is not None:
sign = "+" if roi >= 0 else ""
lines.append(f"ROI: {sign}{roi:.1f}%")
return "\n".join(lines)
async def _handle_auctions(db: AsyncSession, user: User, hours: int, path: str) -> str:
"""Handle auctions query."""
result = await execute_tool(
db, user, "market_feed",
{"ending_within_hours": hours, "limit": 10, "sort_by": "time"},
path=path
)
if "error" in result:
return f"Could not load auctions: {result['error']}"
items = result.get("items", [])
if not items:
return f"No auctions ending in the next {hours} hours."
lines = [f"Auctions Ending Soon ({len(items)} found)", ""]
for a in items[:10]:
domain = a.get("domain", "unknown")
bid = a.get("current_bid", 0)
platform = a.get("platform", "")
lines.append(f"{domain} - ${bid:.0f} ({platform})")
return "\n".join(lines)
async def _handle_drops(db: AsyncSession, user: User, path: str) -> str:
"""Handle drops query."""
result = await execute_tool(
db, user, "get_drops",
{"hours": 24, "limit": 15},
path=path
)
if "error" in result:
return f"Could not load drops: {result['error']}"
domains = result.get("domains", [])
total = result.get("total", 0)
if not domains:
return "No recently dropped domains found."
lines = [f"Recently Dropped ({total} total)", ""]
for d in domains[:15]:
name = d if isinstance(d, str) else d.get("domain", "unknown")
lines.append(f"{name}")
if total > 15:
lines.append(f"\n... and {total - 15} more")
return "\n".join(lines)
async def _handle_listings(db: AsyncSession, user: User, path: str) -> str:
"""Handle listings query."""
result = await execute_tool(db, user, "list_my_listings", {"limit": 10}, path=path)
if "error" in result:
return f"Could not load listings: {result['error']}"
items = result.get("items", [])
if not items:
return "You have no active listings.\n\nGo to For Sale to create your first listing."
lines = [f"Your Listings ({len(items)})", ""]
for l in items[:10]:
domain = l.get("domain", "unknown")
status = l.get("status", "")
price = l.get("asking_price")
price_str = f"${price:,.0f}" if price else "Make Offer"
lines.append(f"{domain} - {price_str} [{status}]")
return "\n".join(lines)
async def _handle_inbox(db: AsyncSession, user: User, path: str) -> str:
"""Handle inbox query."""
result = await execute_tool(db, user, "get_inbox_counts", {}, path=path)
if "error" in result:
return f"Could not load inbox: {result['error']}"
buyer = result.get("buyer_unread", 0)
seller = result.get("seller_unread", 0)
total = buyer + seller
if total == 0:
return "No unread messages.\n\nYour inbox is all caught up!"
lines = [
f"Inbox Summary",
"",
f"Unread: {total}",
f"• As Buyer: {buyer}",
f"• As Seller: {seller}",
"",
"Go to Inbox to view conversations.",
]
return "\n".join(lines)
async def _handle_sniper(db: AsyncSession, user: User, path: str) -> str:
"""Handle sniper alerts query."""
result = await execute_tool(db, user, "list_sniper_alerts", {"limit": 10}, path=path)
if "error" in result:
return f"Could not load alerts: {result['error']}"
items = result.get("items", [])
if not items:
return "No sniper alerts configured.\n\nGo to Sniper to create custom alerts."
active = sum(1 for a in items if a.get("is_active"))
lines = [f"Sniper Alerts ({active} active)", ""]
for a in items[:10]:
name = a.get("name", "Unnamed")
status = "" if a.get("is_active") else ""
lines.append(f"{status} {name}")
return "\n".join(lines)
async def _handle_yield(db: AsyncSession, user: User, path: str) -> str:
"""Handle yield query."""
result = await execute_tool(db, user, "yield_dashboard", {}, path=path)
if "error" in result:
return f"Could not load yield data: {result['error']}"
stats = result.get("stats", {})
if stats.get("total_domains", 0) == 0:
return "No yield domains configured.\n\nGo to Yield to activate monetization."
lines = [
"Yield Summary",
"",
f"Active Domains: {stats.get('active_domains', 0)}",
f"Monthly Revenue: ${stats.get('monthly_revenue', 0):.2f}",
f"Monthly Clicks: {stats.get('monthly_clicks', 0)}",
f"Conversions: {stats.get('monthly_conversions', 0)}",
]
return "\n".join(lines)
# ============================================================================
# MAIN ENTRY POINT
# ============================================================================
async def process_message(
db: AsyncSession,
user: User,
message: str,
path: str,
) -> str:
"""
Process a user message and return a response.
This is the main entry point for the Hunter Companion.
"""
# Step 1: Detect intent (pure code, no LLM)
detected = detect_intent(message)
# Step 2: Handle the intent and generate response
response = await handle_intent(db, user, detected, path)
return response