diff --git a/backend/app/services/llm_agent.py b/backend/app/services/llm_agent.py index 7e12ff5..dd745f8 100644 --- a/backend/app/services/llm_agent.py +++ b/backend/app/services/llm_agent.py @@ -1,46 +1,23 @@ +""" +Hunter Companion Agent - Simplified and Controlled +""" from __future__ import annotations import json +import re from typing import Any, AsyncIterator, Optional from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings -from app.models.subscription import Subscription, SubscriptionTier +from app.models.subscription import Subscription from app.models.user import User from app.services.llm_gateway import chat_completions, chat_completions_stream from app.services.llm_tools import execute_tool, tool_catalog_for_prompt - settings = get_settings() -def _is_greeting(text: str) -> bool: - t = (text or "").strip().lower() - if not t: - return False - # common minimal greetings - greetings = { - "hi", - "hello", - "hey", - "yo", - "sup", - "hola", - "hallo", - "guten tag", - "good morning", - "good evening", - "good afternoon", - } - if t in greetings: - return True - # very short greeting-like messages - if len(t) <= 6 and t.replace("!", "").replace(".", "") in greetings: - return True - return False - - def _tier_level(tier: str) -> int: t = (tier or "").lower() if t == "tycoon": @@ -52,7 +29,6 @@ def _tier_level(tier: str) -> int: async def _get_user_tier(db: AsyncSession, user: User) -> str: from sqlalchemy import select - res = await db.execute(select(Subscription).where(Subscription.user_id == user.id)) sub = res.scalar_one_or_none() if not sub: @@ -60,29 +36,81 @@ async def _get_user_tier(db: AsyncSession, user: User) -> str: return sub.tier.value +# Pre-defined responses for common queries (bypass LLM) +CANNED_RESPONSES = { + "greeting": ( + "Hey! I'm your Hunter Companion.\n\n" + "I can help you with:\n" + "- Analyzing domains (just tell me the domain)\n" + "- Showing auctions ending soon\n" + "- Checking your watchlist or portfolio\n" + "- Finding dropped domains\n\n" + "What would you like to do?" + ), + "capabilities": ( + "Here's what I can do:\n\n" + "- Analyze any domain (Pounce Score, risk, value)\n" + "- Show current auctions and deals\n" + "- List recently dropped domains\n" + "- Check your watchlist status\n" + "- Review your portfolio performance\n" + "- Show your listings and leads\n\n" + "Just ask! For domain analysis, simply type the domain name." + ), +} + + +def _is_greeting(text: str) -> bool: + t = (text or "").strip().lower() + greetings = {"hi", "hello", "hey", "yo", "sup", "hola", "hallo", "guten tag", "moin"} + clean = t.replace("!", "").replace(".", "").replace("?", "") + return clean in greetings or len(clean) <= 3 and clean in greetings + + +def _is_capabilities_question(text: str) -> bool: + t = (text or "").strip().lower() + patterns = [ + "what can you do", + "what do you do", + "help", + "how can you help", + "what are you", + "who are you", + "capabilities", + "features", + "was kannst du", + "was machst du", + "hilfe", + ] + return any(p in t for p in patterns) + + +def _extract_domain(text: str) -> Optional[str]: + """Extract a domain name from user text.""" + t = (text or "").strip().lower() + # Pattern: word.tld or word.word.tld + pattern = r'\b([a-z0-9][-a-z0-9]*\.)+[a-z]{2,}\b' + match = re.search(pattern, t) + if match: + domain = match.group(0) + # Filter out common non-domains + if domain not in {"example.com", "test.com", "domain.com"}: + return domain + return None + + def _build_system_prompt(path: str) -> str: tools = tool_catalog_for_prompt(path) return ( - "You are the Pounce Hunter Companion, a domain trading expert. Always respond in English.\n\n" - "CRITICAL RULES:\n" - "1. NEVER invent or hallucinate data. You do NOT have access to SEMrush, Estibot, GoDaddy sales, or external databases.\n" - "2. If you don't have data, say so honestly. Only use data from tools you actually called.\n" - "3. Keep responses SHORT: 2-3 sentences max, then bullets if needed.\n" - "4. NO markdown: no ** or *, no code blocks, no headers with #.\n" - "5. Use dashes (-) for bullet points.\n\n" - "WHAT YOU CAN DO:\n" - "- Analyze domains using the analyze_domain tool (gives Pounce Score, risk, value estimate)\n" - "- Show user's watchlist, portfolio, listings, inbox, yield data\n" - "- Search auctions and drops\n" - "- Generate brandable names\n\n" - "WHAT YOU CANNOT DO:\n" - "- Access external sales databases or SEO tools\n" - "- Look up real-time WHOIS or DNS (unless via tool)\n" - "- Make up sales history or traffic stats\n\n" - "TOOL USAGE:\n" - "- To call a tool, respond with ONLY: {\"tool_calls\":[{\"name\":\"...\",\"args\":{...}}]}\n" - "- After tool results, summarize briefly without mentioning tools.\n\n" - f"TOOLS:\n{json.dumps(tools, ensure_ascii=False)}\n" + "You are a domain trading assistant. Be brief and helpful.\n\n" + "RULES:\n" + "- Give SHORT answers (2-3 sentences max)\n" + "- Do NOT make up data. Only state facts from tool results.\n" + "- Do NOT format with markdown (no ** or *)\n" + "- If unsure, ask the user to clarify\n\n" + "TOOLS (call with JSON):\n" + f"{json.dumps([{'name': t['name'], 'description': t['description']} for t in tools], indent=2)}\n\n" + "To call a tool: {\"tool_calls\":[{\"name\":\"...\",\"args\":{}}]}" ) @@ -97,22 +125,64 @@ def _try_parse_tool_calls(text: str) -> Optional[list[dict[str, Any]]]: calls = obj.get("tool_calls") if not isinstance(calls, list): return None - out: list[dict[str, Any]] = [] + out = [] for c in calls: - if not isinstance(c, dict): - continue - name = c.get("name") - args = c.get("args") or {} - if isinstance(name, str) and isinstance(args, dict): - out.append({"name": name, "args": args}) + if isinstance(c, dict) and isinstance(c.get("name"), str): + out.append({"name": c["name"], "args": c.get("args") or {}}) return out or None -def _truncate_json(value: Any, max_chars: int = 8000) -> str: +def _truncate_json(value: Any, max_chars: int = 6000) -> str: s = json.dumps(value, ensure_ascii=False) - if len(s) <= max_chars: - return s - return s[: max_chars - 3] + "..." + return s[:max_chars] if len(s) > max_chars else s + + +def _format_analysis_result(data: dict) -> str: + """Format domain analysis result into readable text.""" + if "error" in data: + return f"Could not analyze: {data['error']}" + + domain = data.get("domain", "unknown") + score = data.get("pounce_score", 0) + + # Determine recommendation + if score >= 70: + rec = "BUY" + rec_reason = "Strong domain with good fundamentals" + elif score >= 50: + rec = "CONSIDER" + rec_reason = "Decent potential, evaluate based on your needs" + else: + rec = "SKIP" + rec_reason = "Limited potential or high risk" + + lines = [ + f"Domain: {domain}", + f"Pounce Score: {score}/100", + f"Recommendation: {rec}", + "", + ] + + # Add key metrics + if data.get("availability"): + avail = data["availability"] + status = "Available" if avail.get("is_available") else "Taken" + lines.append(f"Status: {status}") + + if data.get("value"): + val = data["value"] + if val.get("estimated_value"): + lines.append(f"Est. Value: ${val['estimated_value']:,.0f}") + + if data.get("risk"): + risk = data["risk"] + risk_level = risk.get("risk_level", "unknown") + lines.append(f"Risk: {risk_level}") + + lines.append("") + lines.append(rec_reason) + + return "\n".join(lines) async def run_agent( @@ -122,37 +192,40 @@ async def run_agent( messages: list[dict[str, Any]], path: str, model: Optional[str] = None, - temperature: float = 0.7, - max_steps: int = 6, + temperature: float = 0.3, + max_steps: int = 4, ) -> list[dict[str, Any]]: - """ - Runs a small tool loop to augment context, returning final messages to be used - for the final answer generation (optionally streamed). - """ + """Run the agent with simplified logic.""" tier = await _get_user_tier(db, user) if _tier_level(tier) < 2: - raise PermissionError("Chat is available on Trader and Tycoon plans. Upgrade to unlock.") + raise PermissionError("Hunter Companion requires Trader or Tycoon plan.") + + # Get last user message + last_user = next((m for m in reversed(messages or []) if m.get("role") == "user"), None) + user_text = str(last_user.get("content", "")) if last_user else "" base = [ {"role": "system", "content": _build_system_prompt(path)}, - {"role": "system", "content": f"Context: current_terminal_path={path}; tier={tier}."}, ] + + # Handle canned responses (bypass LLM entirely) + if _is_greeting(user_text): + return base + messages + [{"role": "assistant", "content": CANNED_RESPONSES["greeting"]}] + + if _is_capabilities_question(user_text): + return base + messages + [{"role": "assistant", "content": CANNED_RESPONSES["capabilities"]}] + + # Auto-detect domain and analyze + domain = _extract_domain(user_text) + if domain: + # Directly call analyze_domain tool + result = await execute_tool(db, user, "analyze_domain", {"domain": domain}, path=path) + formatted = _format_analysis_result(result) + return base + messages + [{"role": "assistant", "content": formatted}] + + # For other queries, use LLM with tool loop convo = base + (messages or []) - - # If the user just greets, answer naturally without tool-looping. - last_user = next((m for m in reversed(messages or []) if m.get("role") == "user"), None) - if last_user and _is_greeting(str(last_user.get("content") or "")): - convo.append( - { - "role": "assistant", - "content": ( - "Hey! What can I help you with?\n\n" - "Give me a domain to analyze, or ask about your watchlist, portfolio, or current auctions." - ), - } - ) - return convo - + for _ in range(max_steps): payload = { "model": model or settings.llm_default_model, @@ -165,51 +238,52 @@ async def run_agent( tool_calls = _try_parse_tool_calls(content) if not tool_calls: - # append assistant and stop convo.append({"role": "assistant", "content": content}) return convo - # append the tool request as assistant message (so model can see its own plan) convo.append({"role": "assistant", "content": content}) - for call in tool_calls[:5]: # cap per step + for call in tool_calls[:3]: name = call["name"] args = call["args"] result = await execute_tool(db, user, name, args, path=path) - convo.append( - { - "role": "system", - "content": ( - f"TOOL_RESULT_INTERNAL name={name} json={_truncate_json(result)}. " - "This is internal context. Do NOT quote or display this to the user." - ), - } - ) + + # Format specific tool results + if name == "analyze_domain": + formatted = _format_analysis_result(result) + convo.append({"role": "system", "content": f"Tool result:\n{formatted}"}) + else: + convo.append({"role": "system", "content": f"Tool {name} result: {_truncate_json(result)}"}) - # Fallback: force final answer even if tool loop didn't converge - convo.append( - { - "role": "system", - "content": "Now answer the user with the best possible answer using the tool results. Do NOT request tools.", - } - ) return convo -async def stream_final_answer(convo: list[dict[str, Any]], *, model: Optional[str], temperature: float) -> AsyncIterator[bytes]: +async def stream_final_answer( + convo: list[dict[str, Any]], + *, + model: Optional[str], + temperature: float +) -> AsyncIterator[bytes]: + """Stream the final answer.""" + # Check if last message is already a complete assistant response + if convo and convo[-1].get("role") == "assistant": + content = convo[-1].get("content", "") + if content and not content.strip().startswith("{"): + # Already have a good response, stream it directly + chunk = { + "choices": [{"delta": {"content": content}}] + } + yield f"data: {json.dumps(chunk)}\n\n".encode() + yield b"data: [DONE]\n\n" + return + + # Otherwise, ask LLM to summarize payload = { "model": model or settings.llm_default_model, - "messages": convo - + [ + "messages": convo + [ { "role": "system", - "content": ( - "Respond now. Rules:\n" - "- NEVER invent data. Only use data from tools you called.\n" - "- Keep it SHORT: 2-3 sentences, then bullet points if needed.\n" - "- NO markdown (no ** or *), just plain text with dashes for bullets.\n" - "- Do NOT mention tools or JSON." - ), + "content": "Give a brief, helpful response based on the data. No markdown formatting. Keep it short.", } ], "temperature": temperature, @@ -217,5 +291,3 @@ async def stream_final_answer(convo: list[dict[str, Any]], *, model: Optional[st } async for chunk in chat_completions_stream(payload): yield chunk - - diff --git a/frontend/src/components/chat/HunterCompanion.tsx b/frontend/src/components/chat/HunterCompanion.tsx index 976ceed..b106cfe 100644 --- a/frontend/src/components/chat/HunterCompanion.tsx +++ b/frontend/src/components/chat/HunterCompanion.tsx @@ -12,19 +12,12 @@ import { Loader2, Lock, Trash2, - TrendingUp, - Search, - Briefcase, - Target, - Zap, } from 'lucide-react' import { useStore } from '@/lib/store' -type Role = 'system' | 'user' | 'assistant' - type ChatMessage = { id: string - role: Exclude + role: 'user' | 'assistant' content: string createdAt: number } @@ -42,7 +35,7 @@ function getApiBase(): string { } async function streamChat(opts: { - messages: Array<{ role: Role; content: string }> + messages: Array<{ role: string; content: string }> path: string onDelta: (delta: string) => void }): Promise { @@ -52,7 +45,7 @@ async function streamChat(opts: { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: opts.messages, - temperature: 0.5, + temperature: 0.3, stream: true, path: opts.path, }), @@ -68,6 +61,7 @@ async function streamChat(opts: { const decoder = new TextDecoder('utf-8') let buffer = '' + for (;;) { const { value, done } = await reader.read() if (done) break @@ -75,6 +69,7 @@ async function streamChat(opts: { const parts = buffer.split('\n\n') buffer = parts.pop() || '' + for (const p of parts) { const line = p.split('\n').find((l) => l.startsWith('data: ')) if (!line) continue @@ -85,7 +80,7 @@ async function streamChat(opts: { const delta = json?.choices?.[0]?.delta?.content if (typeof delta === 'string' && delta.length) opts.onDelta(delta) } catch { - // ignore + // ignore parse errors } } } @@ -98,122 +93,20 @@ function getTier(subscription: any): 'scout' | 'trader' | 'tycoon' { return 'scout' } -// Format message text to clean HTML with proper spacing -function formatMessage(text: string): string { - if (!text) return '' - - // Escape HTML first - let html = text - .replace(/&/g, '&') - .replace(//g, '>') - - // Remove markdown formatting (** and * for bold/italic) - html = html - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*([^*]+)\*/g, '$1') - .replace(/__(.+?)__/g, '$1') - .replace(/_([^_]+)_/g, '$1') - - // Split into paragraphs (double newline = paragraph break) - const paragraphs = html.split(/\n\n+/) - - const formatted = paragraphs.map(para => { - // Check if this paragraph is a list (starts with - or number.) - const lines = para.split('\n') - const isList = lines.every(line => { - const trimmed = line.trim() - return trimmed === '' || trimmed.startsWith('-') || trimmed.startsWith('•') || /^\d+\./.test(trimmed) - }) - - if (isList) { - // Format as list - const items = lines - .map(line => line.trim()) - .filter(line => line) - .map(line => { - // Remove leading dash, bullet, or number - const content = line.replace(/^[-•]\s*/, '').replace(/^\d+\.\s*/, '') - return `
${content}
` - }) - return `
${items.join('')}
` - } else { - // Regular paragraph - convert single newlines to line breaks - return `

${para.replace(/\n/g, '
')}

` - } - }) - - return formatted.join('') -} +// Quick action suggestions +const SUGGESTIONS = [ + { label: 'Analyze a domain', prompt: 'analyze ' }, + { label: 'Ending soon', prompt: 'Show auctions ending in 2 hours' }, + { label: 'My watchlist', prompt: 'Show my watchlist' }, + { label: 'Portfolio stats', prompt: 'Show my portfolio summary' }, +] -// Suggestion chips based on current page -function getSuggestions(path: string): Array<{ label: string; prompt: string }> { - const p = (path || '').split('?')[0] - - if (p.startsWith('/terminal/hunt')) { - return [ - { label: 'Ending soon', prompt: 'Show auctions ending in the next 2 hours' }, - { label: 'Find brandables', prompt: 'Generate 10 available 5-letter brandable .com domains' }, - { label: 'Trending now', prompt: 'What keywords are trending today?' }, - ] - } - if (p.startsWith('/terminal/market')) { - return [ - { label: 'Best deals', prompt: 'Show the best deals under $100' }, - { label: 'Ending today', prompt: 'What auctions are ending today?' }, - ] - } - if (p.startsWith('/terminal/watchlist')) { - return [ - { label: 'My watchlist', prompt: 'Show my watchlist' }, - { label: 'Analyze top', prompt: 'Analyze my most recently added domain' }, - ] - } - if (p.startsWith('/terminal/portfolio')) { - return [ - { label: 'Portfolio ROI', prompt: 'What is my overall portfolio ROI?' }, - { label: 'Renewals', prompt: 'Which domains are up for renewal soon?' }, - { label: 'Top performers', prompt: 'Show my top 5 domains by value' }, - ] - } - if (p.startsWith('/terminal/sniper')) { - return [ - { label: 'My alerts', prompt: 'Show my sniper alerts' }, - { label: 'Matching now', prompt: 'Are there auctions matching my alerts right now?' }, - ] - } - if (p.startsWith('/terminal/listing')) { - return [ - { label: 'My listings', prompt: 'Show my active listings' }, - { label: 'New leads', prompt: 'Do I have any new leads?' }, - ] - } - if (p.startsWith('/terminal/inbox')) { - return [ - { label: 'Unread count', prompt: 'How many unread messages do I have?' }, - { label: 'Recent threads', prompt: 'Show my recent inquiry threads' }, - ] - } - if (p.startsWith('/terminal/yield')) { - return [ - { label: 'Yield stats', prompt: 'Show my yield dashboard stats' }, - { label: 'Top earners', prompt: 'Which domains are earning the most?' }, - ] - } - // Default - return [ - { label: 'Market snapshot', prompt: 'Give me a quick market snapshot' }, - { label: 'Analyze domain', prompt: 'Analyze ' }, - ] -} - -// Teaser examples for Scout -const TEASER_EXAMPLES = [ - { icon: Search, text: '"Analyze startup.io — should I buy?"' }, - { icon: TrendingUp, text: '"What auctions are ending in 2 hours?"' }, - { icon: Briefcase, text: '"What\'s my portfolio ROI?"' }, - { icon: Target, text: '"Find 5-letter .com brandables"' }, - { icon: Zap, text: '"Any new leads on my listings?"' }, +// Teaser for Scout users +const TEASER_ITEMS = [ + 'Analyze any domain instantly', + 'Get BUY / SKIP recommendations', + 'Track auctions and drops', + 'Monitor your portfolio', ] export function HunterCompanion() { @@ -224,7 +117,7 @@ export function HunterCompanion() { const storageKey = useMemo(() => { const uidPart = user?.id ? String(user.id) : 'anon' - return `pounce:hunter_companion:v2:${uidPart}` + return `pounce:hc:v3:${uidPart}` }, [user?.id]) const [open, setOpen] = useState(false) @@ -235,7 +128,7 @@ export function HunterCompanion() { const listRef = useRef(null) const inputRef = useRef(null) - // Load from localStorage + // Load messages useEffect(() => { if (!canChat) return try { @@ -244,32 +137,28 @@ export function HunterCompanion() { const parsed = JSON.parse(raw) if (Array.isArray(parsed)) setMessages(parsed) } - } catch { - // ignore - } + } catch {} }, [storageKey, canChat]) - // Save to localStorage + // Save messages useEffect(() => { - if (!canChat) return + if (!canChat || messages.length === 0) return try { - localStorage.setItem(storageKey, JSON.stringify(messages.slice(-60))) - } catch { - // ignore - } + localStorage.setItem(storageKey, JSON.stringify(messages.slice(-50))) + } catch {} }, [messages, storageKey, canChat]) // Auto-scroll useEffect(() => { - if (!open) return - const el = listRef.current - if (el) el.scrollTop = el.scrollHeight - }, [open, messages.length]) + if (open && listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight + } + }, [open, messages]) // Focus input useEffect(() => { if (open && canChat) { - setTimeout(() => inputRef.current?.focus(), 50) + setTimeout(() => inputRef.current?.focus(), 100) } }, [open, canChat]) @@ -282,11 +171,12 @@ export function HunterCompanion() { const userMsg: ChatMessage = { id: uid(), role: 'user', content: msg, createdAt: Date.now() } const assistantId = uid() + setMessages((prev) => [...prev, userMsg, { id: assistantId, role: 'assistant', content: '', createdAt: Date.now() }]) setInput('') setSending(true) - const history = messages.slice(-20).map((m) => ({ role: m.role as Role, content: m.content })) + const history = messages.slice(-10).map((m) => ({ role: m.role, content: m.content })) try { await streamChat({ @@ -294,14 +184,14 @@ export function HunterCompanion() { path: pathname || '/terminal/hunt', onDelta: (delta) => { setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: (m.content || '') + delta } : m)) + prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + delta } : m)) ) }, }) } catch (e: any) { setMessages((prev) => prev.map((m) => - m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Request failed'}` } : m + m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Failed'}` } : m ) ) } finally { @@ -312,75 +202,51 @@ export function HunterCompanion() { const clear = () => { setMessages([]) - try { - localStorage.removeItem(storageKey) - } catch { - // ignore - } + try { localStorage.removeItem(storageKey) } catch {} } - const suggestions = getSuggestions(pathname || '/terminal/hunt') - return ( <> - {/* FAB */} + {/* Floating Button */} - {/* Panel */} + {/* Chat Panel */} {open && (
-
setOpen(false)} /> +
setOpen(false)} /> -
+
+ {/* Header */} -
-
-
- {canChat ? ( -
- ) : ( - - )} - - Hunter Companion - - {!canChat && ( - - LOCKED - - )} -
-
- {canChat ? 'Domain trading AI assistant' : 'Upgrade to unlock'} -
+
+
+ {canChat ? ( + + ) : ( + + )} + + Hunter Companion +
-
+
{canChat && messages.length > 0 && ( - )} -
@@ -389,54 +255,39 @@ export function HunterCompanion() { {/* Body */}
{!canChat ? ( - /* Scout Teaser */ -
-
-
- -
-

AI Trading Assistant

-

- Get instant domain analysis, market insights, portfolio advice, and deal recommendations. -

- -
- {TEASER_EXAMPLES.map((ex, i) => ( -
- - {ex.text} -
- ))} -
- - - Upgrade to Trader - -

- Available on Trader & Tycoon plans -

+ // Scout Teaser +
+
+
+

AI Trading Assistant

+

+ Get instant domain analysis and trading recommendations. +

+
+ {TEASER_ITEMS.map((item, i) => ( +
+ + {item} +
+ ))} +
+ + Upgrade to Trader +
) : messages.length === 0 ? ( - /* Empty state for Trader/Tycoon */ -
-
- -
-

Ready to hunt

-

- Ask me anything about domains, auctions, or your portfolio. + // Empty state +

+ +

+ Ask me about domains, auctions, or your portfolio.

- - {/* Quick suggestions */}
- {suggestions.map((s, i) => ( + {SUGGESTIONS.map((s, i) => ( @@ -455,52 +306,30 @@ export function HunterCompanion() {
) : ( - /* Chat messages */ + // Messages
{messages.map((m) => (
- {m.role === 'assistant' ? ( -
- ) : ( - {m.content} - )} + {m.content || (sending && m.role === 'assistant' ? '...' : '')}
))} - - {/* Suggestion chips after messages */} - {!sending && messages.length > 0 && ( -
- {suggestions.slice(0, 2).map((s, i) => ( - - ))} -
- )}
)}
- {/* Input (only for Trader/Tycoon) */} + {/* Input */} {canChat && ( -
-
+
+
@@ -533,25 +357,6 @@ export function HunterCompanion() {
)} - - {/* Styles for formatted messages */} - ) }