mirror of
https://github.com/Kartvaya2008/autostream-ai-agent
synced 2026-04-21 07:37:34 +00:00
Initial commit
This commit is contained in:
commit
a17102a7f8
18 changed files with 2148 additions and 0 deletions
250
README.md
Normal file
250
README.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# 🤖 AutoStream AI Agent
|
||||
### Conversational AI + Lead Generation System
|
||||
|
||||
> An intelligent support agent that understands user intent, retrieves answers from a local knowledge base, detects high-intent leads, and captures user details — all through a clean, real-time chat interface.
|
||||
|
||||
---
|
||||
|
||||
## 📌 Overview
|
||||
|
||||
**AutoStream AI Agent** is a full-stack conversational AI system built for [ServiceHive's Inflx platform](https://servicehive.io). It simulates a real-world SaaS support assistant that goes beyond answering questions — it identifies when a user is ready to convert, collects their details, and triggers a lead capture workflow automatically.
|
||||
|
||||
This project demonstrates a production-grade approach to building agentic AI systems using **LangChain**, **Gemini 1.5 Flash**, and a **RAG pipeline** backed by a local JSON knowledge base.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Intent Detection** — Classifies every user message as a greeting, product inquiry, or high-intent lead
|
||||
- **RAG-Powered Responses** — Retrieves accurate answers from a local knowledge base (no hallucinations)
|
||||
- **Lead Capture Workflow** — Collects name, email, and creator platform when high intent is detected
|
||||
- **Tool Execution** — Calls `mock_lead_capture()` only after all required fields are collected
|
||||
- **Session Memory** — Retains full conversation context across 5–6 turns
|
||||
- **FastAPI Backend** — Clean REST API with a `/chat` endpoint
|
||||
- **Responsive Chat UI** — Professional light/dark-mode frontend built with HTML, CSS, and JS
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Language | Python 3.10+ |
|
||||
| Backend Framework | FastAPI |
|
||||
| LLM | Gemini 1.5 Flash (via LangChain) |
|
||||
| Agent Framework | LangChain / Custom Agent Logic |
|
||||
| Knowledge Base | JSON file (local RAG) |
|
||||
| Memory | LangChain `ConversationBufferMemory` |
|
||||
| Frontend | HTML5, CSS3, Vanilla JS |
|
||||
| Environment | `python-dotenv` |
|
||||
|
||||
---
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
autostream-ai-agent/
|
||||
│
|
||||
├── backend/
|
||||
│ ├── agent.py # Main agent orchestration logic
|
||||
│ ├── app.py # FastAPI server + /api/chat endpoint
|
||||
│ ├── intent.py # Intent classification (greeting / inquiry / lead)
|
||||
│ ├── rag.py # RAG pipeline — knowledge base retrieval
|
||||
│ ├── memory.py # Conversation session memory management
|
||||
│ ├── tools.py # Tool definitions + mock_lead_capture()
|
||||
│ └── knowledge_base.json # Local knowledge base (pricing, features, policies)
|
||||
│
|
||||
├── frontend/
|
||||
│ ├── index.html # Chat UI (single-page)
|
||||
│ └── script.js # Frontend logic — send, receive, render messages
|
||||
│
|
||||
├── .env # API keys (not committed)
|
||||
├── .env.example # Example environment variables
|
||||
├── requirements.txt # Python dependencies
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ How to Run Locally
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/autostream-ai-agent.git
|
||||
cd autostream-ai-agent
|
||||
```
|
||||
|
||||
### 2. Create a Virtual Environment
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # macOS / Linux
|
||||
venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Configure Environment Variables
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open `.env` and add your credentials:
|
||||
|
||||
```env
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
```
|
||||
|
||||
### 5. Start the Backend Server
|
||||
|
||||
```bash
|
||||
uvicorn backend.app:app --reload --port 8000
|
||||
```
|
||||
|
||||
### 6. Open the Frontend
|
||||
|
||||
Open `frontend/index.html` directly in your browser, or serve it with:
|
||||
|
||||
```bash
|
||||
python -m http.server 5500 --directory frontend
|
||||
```
|
||||
|
||||
Then visit: **http://localhost:5500**
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### `POST /api/chat`
|
||||
|
||||
Send a user message and receive an AI-generated response.
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"system": "You are AutoStream AI...",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "What is the Pro plan pricing?" }
|
||||
],
|
||||
"mode": "chat"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "The Pro plan is priced at $79/month and includes unlimited videos, 4K resolution, and AI captions.",
|
||||
"intent": "product_inquiry"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `200` | Success |
|
||||
| `400` | Bad request — missing fields |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Explanation
|
||||
|
||||
### Why LangChain?
|
||||
|
||||
LangChain was chosen for its modular architecture — it separates concerns cleanly between the LLM, memory, tools, and retrieval layers. This makes it straightforward to swap components (e.g., replace Gemini with Claude or GPT) without rewriting the agent logic.
|
||||
|
||||
### How State is Managed
|
||||
|
||||
Conversation state is managed using LangChain's `ConversationBufferMemory`, which stores the full message history in-memory per session. Each API call includes the complete history so the LLM retains context across turns. For multi-user scenarios, sessions are keyed by a unique `chat_id` generated at the start of each conversation.
|
||||
|
||||
### RAG Pipeline
|
||||
|
||||
The knowledge base (`knowledge_base.json`) stores AutoStream's pricing, features, and policies as structured documents. On each user query, `rag.py` performs a similarity search against this knowledge base and injects the most relevant context into the LLM prompt — ensuring responses are grounded in factual product data rather than model hallucinations.
|
||||
|
||||
### Tool Execution (Lead Capture)
|
||||
|
||||
`tools.py` defines `mock_lead_capture()` as a LangChain tool. The agent is instructed to call this function **only** after collecting the user's name, email, and creator platform — preventing premature triggering. The agent collects each field conversationally, one at a time, before executing the tool.
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Demo Conversation Flow
|
||||
|
||||
```
|
||||
User → "Hi there"
|
||||
Agent → "Hello! I'm AutoStream AI. How can I assist you today?"
|
||||
|
||||
User → "What are your pricing plans?"
|
||||
Agent → [RAG retrieval] "We offer two plans:
|
||||
Basic — $29/month (10 videos, 720p)
|
||||
Pro — $79/month (Unlimited, 4K, AI captions)"
|
||||
|
||||
User → "The Pro plan sounds great. I run a YouTube channel."
|
||||
Agent → [High intent detected] "That's great to hear! May I get your name?"
|
||||
|
||||
User → "Alex"
|
||||
Agent → "Thank you, Alex. Could you share your email address?"
|
||||
|
||||
User → "alex@gmail.com"
|
||||
Agent → "Got it. And which platform are you primarily creating for?"
|
||||
|
||||
User → "YouTube"
|
||||
Agent → [Calls mock_lead_capture("Alex", "alex@gmail.com", "YouTube")]
|
||||
"Thank you, Alex. Your details have been captured.
|
||||
Welcome to AutoStream — our team will be in touch shortly."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 WhatsApp Integration (Deployment Theory)
|
||||
|
||||
To deploy this agent on WhatsApp, the recommended approach is using the **WhatsApp Business API** via **Meta's Cloud API** combined with a **webhook-based architecture**:
|
||||
|
||||
1. **Register a webhook** on Meta Developer Portal pointing to a public endpoint (e.g., `https://yourdomain.com/webhook`)
|
||||
2. When a user sends a WhatsApp message, Meta sends a `POST` request to your webhook with the message payload
|
||||
3. Your FastAPI server receives the payload, extracts the message text and sender ID, and routes it through the existing agent pipeline
|
||||
4. The agent's response is sent back to the user via a `POST` request to the WhatsApp Send Message API using the user's phone number as the session identifier
|
||||
5. **Session memory** is maintained per phone number using a dictionary or Redis store
|
||||
|
||||
> This approach requires no change to the core agent logic — only a new webhook handler and a WhatsApp API client (e.g., `pywa`, `whatsapp-python`, or direct HTTP calls).
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Future Improvements
|
||||
|
||||
- [ ] **Vector database** (Pinecone / ChromaDB) for scalable RAG beyond JSON
|
||||
- [ ] **Streaming responses** for real-time token-by-token output
|
||||
- [ ] **User authentication** and persistent session storage (PostgreSQL / Redis)
|
||||
- [ ] **WhatsApp & Telegram deployment** via webhook integrations
|
||||
- [ ] **Analytics dashboard** — track intent distribution and lead conversion rate
|
||||
- [ ] **Multi-language support** for global content creator audiences
|
||||
- [ ] **Human handoff** — escalate to a live agent when confidence is low
|
||||
|
||||
---
|
||||
|
||||
## 👤 Author
|
||||
|
||||
**Built for ServiceHive — Inflx Platform**
|
||||
Machine Learning Intern Assignment — Social-to-Lead Agentic Workflow
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Project | AutoStream AI Agent |
|
||||
| Stack | Python · FastAPI · LangChain · Gemini · HTML/JS |
|
||||
| Assignment | ServiceHive / Inflx — ML Intern Take-Home |
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**AutoStream AI Agent** — Built with precision. Designed for conversion.
|
||||
|
||||
</div>
|
||||
10
backend/.env.example
Normal file
10
backend/.env.example
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# AutoStream Agent — Environment Variables
|
||||
# Copy to .env and fill in your values
|
||||
|
||||
# ── Required ──────────────────────────────
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
|
||||
# ── Optional ──────────────────────────────
|
||||
GEMINI_MODEL=gemini-1.5-flash
|
||||
LOG_LEVEL=INFO
|
||||
ADMIN_SECRET=change_me_in_production
|
||||
BIN
backend/__pycache__/agent.cpython-311.pyc
Normal file
BIN
backend/__pycache__/agent.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/app.cpython-311.pyc
Normal file
BIN
backend/__pycache__/app.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/intent.cpython-311.pyc
Normal file
BIN
backend/__pycache__/intent.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/memory.cpython-311.pyc
Normal file
BIN
backend/__pycache__/memory.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/rag.cpython-311.pyc
Normal file
BIN
backend/__pycache__/rag.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/tools.cpython-311.pyc
Normal file
BIN
backend/__pycache__/tools.cpython-311.pyc
Normal file
Binary file not shown.
300
backend/agent.py
Normal file
300
backend/agent.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"""
|
||||
agent.py — Core AutoStream AI Agent
|
||||
Pipeline: Domain Check → Intent → RAG → Decision → Response → Tool
|
||||
Stateful: full entity extraction, skip collected fields, post-completion routing
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from memory import ConversationState, MemoryStore
|
||||
from intent import classify_intent, maybe_correct_intent, is_domain_relevant
|
||||
from rag import RAGPipeline
|
||||
from tools import mock_lead_capture, is_valid_email, normalize_platform
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
||||
|
||||
logger = logging.getLogger("autostream.agent")
|
||||
|
||||
_OPENROUTER_API_KEY = "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"
|
||||
_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
_OPENROUTER_MODEL = "openrouter/auto"
|
||||
_MAX_RETRIES = 3
|
||||
_RETRY_DELAY = 1.5
|
||||
|
||||
|
||||
class OpenRouterClient:
|
||||
def __init__(self):
|
||||
self._llm = ChatOpenAI(
|
||||
model=_OPENROUTER_MODEL,
|
||||
temperature=0.7,
|
||||
openai_api_key=_OPENROUTER_API_KEY,
|
||||
openai_api_base=_OPENROUTER_BASE_URL,
|
||||
)
|
||||
|
||||
def chat(self, messages: list[dict], system: Optional[str] = None,
|
||||
temperature: float = 0.7, max_tokens: int = 512) -> str:
|
||||
lc_messages = []
|
||||
if system:
|
||||
lc_messages.append(SystemMessage(content=system))
|
||||
for m in messages:
|
||||
if m["role"] == "user":
|
||||
lc_messages.append(HumanMessage(content=m["content"]))
|
||||
else:
|
||||
lc_messages.append(AIMessage(content=m["content"]))
|
||||
|
||||
llm = self._llm.bind(temperature=temperature, max_tokens=max_tokens)
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
return llm.invoke(lc_messages).content
|
||||
except Exception as e:
|
||||
logger.warning(f"OpenRouter attempt {attempt}/{_MAX_RETRIES} failed: {e}")
|
||||
if attempt < _MAX_RETRIES:
|
||||
time.sleep(_RETRY_DELAY * attempt)
|
||||
raise RuntimeError("All OpenRouter retries exhausted.")
|
||||
|
||||
|
||||
# ── Prompts ────────────────────────────────────────────────────────────────────
|
||||
|
||||
AGENT_SYSTEM = """You are a professional AI assistant for AutoStream — a SaaS platform with automated video-editing tools for content creators.
|
||||
|
||||
Tone: Clear, professional, slightly friendly. Short responses (2-4 sentences). No unnecessary emojis.
|
||||
|
||||
Knowledge base (ONLY source — DO NOT hallucinate):
|
||||
{context}
|
||||
|
||||
Rules:
|
||||
- Answer ONLY from the context above
|
||||
- If information not found → say "I don't have that information right now"
|
||||
- Never invent features, prices, or policies
|
||||
"""
|
||||
|
||||
LEAD_ASK_NAME = "The user wants to get started with AutoStream. Ask for their name warmly. One sentence only. No emojis."
|
||||
LEAD_ASK_EMAIL = "You have the user's name: {name}. Now ask for their email address. One sentence only."
|
||||
LEAD_ASK_PLATFORM = "You have name={name}, email={email}. Ask which platform they create content on (YouTube, Instagram, TikTok, etc.). One sentence only."
|
||||
|
||||
DOMAIN_REJECTION = "I can only help with AutoStream pricing, features, or getting started. How can I assist you with that?"
|
||||
GREETING_RESPONSE = "Welcome to AutoStream — your AI-powered video editing assistant. How can I help you today?"
|
||||
UNKNOWN_RESPONSE = "I didn't understand that. Are you asking about pricing, features, or getting started with AutoStream?"
|
||||
INVALID_EMAIL_MSG = "That doesn't look like a valid email. Could you double-check and share it again?"
|
||||
ALREADY_SIGNED_UP = "You're already signed up! Our team will reach out to you soon."
|
||||
|
||||
EXTRACT_SYSTEM = """Extract lead fields from the user message. Return ONLY valid JSON, no markdown:
|
||||
{"name": "<string or null>", "email": "<string or null>", "platform": "<string or null>"}
|
||||
|
||||
Rules:
|
||||
- name: first or full name only. Null if not present.
|
||||
- email: must contain "@" and a domain. Null if invalid or not present.
|
||||
- platform: detect from phrases like "YouTube channel", "I create on Instagram", "TikTok creator".
|
||||
Normalise to: YouTube | Instagram | TikTok | Twitter | Facebook | LinkedIn | Twitch
|
||||
Null if not mentioned.
|
||||
- NEVER guess — return null for anything not explicitly stated."""
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_all_fields(text: str, llm: OpenRouterClient) -> dict:
|
||||
try:
|
||||
raw = llm.chat(
|
||||
messages=[{"role": "user", "content": f"User message:\n{text}"}],
|
||||
system=EXTRACT_SYSTEM, temperature=0.0, max_tokens=120,
|
||||
)
|
||||
result = json.loads(raw.strip().replace("```json", "").replace("```", "").strip())
|
||||
return {k: (v if v and str(v).strip() else None) for k, v in result.items()}
|
||||
except Exception as e:
|
||||
logger.warning(f"Entity extraction failed: {e}")
|
||||
return {"name": None, "email": None, "platform": None}
|
||||
|
||||
|
||||
def regex_email(text: str) -> Optional[str]:
|
||||
m = re.search(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", text)
|
||||
return m.group(0) if m else None
|
||||
|
||||
|
||||
def update_lead_state(lead, extracted: dict, raw_text: str):
|
||||
"""Merge extracted fields — never overwrite existing values with null."""
|
||||
if not lead.name and extracted.get("name"):
|
||||
lead.name = extracted["name"].strip().title()
|
||||
logger.info(f"Lead name captured: {lead.name}")
|
||||
|
||||
if not lead.email:
|
||||
candidate = extracted.get("email") or regex_email(raw_text)
|
||||
if candidate and is_valid_email(candidate):
|
||||
lead.email = candidate.strip().lower()
|
||||
logger.info(f"Lead email captured: {lead.email}")
|
||||
|
||||
if not lead.platform and extracted.get("platform"):
|
||||
lead.platform = normalize_platform(extracted["platform"])
|
||||
logger.info(f"Lead platform captured: {lead.platform}")
|
||||
|
||||
lead.current_step = lead.next_missing_field() or "done"
|
||||
|
||||
|
||||
# ── Agent ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AutoStreamAgent:
|
||||
def __init__(self):
|
||||
self.memory = MemoryStore()
|
||||
self.rag = RAGPipeline()
|
||||
self.llm = OpenRouterClient()
|
||||
logger.info("AutoStream Agent initialised (OpenRouter).")
|
||||
|
||||
def respond(self, session_id: str, user_input: str) -> dict:
|
||||
state = self.memory.get(session_id)
|
||||
user_input = user_input.strip()
|
||||
|
||||
if not user_input:
|
||||
return self._build_response("Please say something — I'm here to help!", state)
|
||||
|
||||
# ── GATE 0: Mid lead-collection lock ───────────────────────────────────
|
||||
# Bypass domain/intent while actively collecting lead fields
|
||||
if state.collecting_lead and not state.lead.captured:
|
||||
state.intent = "high_intent"
|
||||
state.intent_confidence = 1.0
|
||||
state.add_message("user", user_input)
|
||||
reply = self._handle_high_intent(state, user_input)
|
||||
state.add_message("assistant", reply)
|
||||
self.memory.save(state)
|
||||
return self._build_response(reply, state)
|
||||
|
||||
# ── GATE 1: Domain restriction ─────────────────────────────────────────
|
||||
if not is_domain_relevant(user_input):
|
||||
state.intent = "unknown"
|
||||
state.add_message("user", user_input)
|
||||
state.add_message("assistant", DOMAIN_REJECTION)
|
||||
self.memory.save(state)
|
||||
logger.info(f"[{session_id}] DOMAIN REJECTED: {user_input[:60]}")
|
||||
return self._build_response(DOMAIN_REJECTION, state)
|
||||
|
||||
# ── GATE 2: Intent detection ───────────────────────────────────────────
|
||||
intent_result = classify_intent(user_input, self.llm)
|
||||
past_intents = state.get_intent_history()
|
||||
intent_result = maybe_correct_intent(intent_result, past_intents, user_input)
|
||||
|
||||
state.intent = intent_result.label
|
||||
state.intent_confidence = intent_result.confidence
|
||||
state.add_message("user", user_input)
|
||||
|
||||
logger.info(
|
||||
f"[{session_id}] turn={state.turn_count} "
|
||||
f"intent={intent_result.label} conf={intent_result.confidence:.2f} "
|
||||
f"method={intent_result.method}"
|
||||
)
|
||||
|
||||
# ── GATE 3: Post-completion routing ────────────────────────────────────
|
||||
# User already signed up — route normally instead of re-triggering lead flow
|
||||
if state.lead.captured:
|
||||
if intent_result.label == "high_intent":
|
||||
reply = ALREADY_SIGNED_UP
|
||||
elif intent_result.label == "info_query":
|
||||
reply = self._handle_info_query(state, user_input)
|
||||
elif intent_result.label == "greeting":
|
||||
reply = GREETING_RESPONSE
|
||||
else:
|
||||
reply = UNKNOWN_RESPONSE
|
||||
state.add_message("assistant", reply)
|
||||
self.memory.save(state)
|
||||
return self._build_response(reply, state)
|
||||
|
||||
# ── GATE 4: Normal routing ─────────────────────────────────────────────
|
||||
if intent_result.label == "greeting":
|
||||
reply = GREETING_RESPONSE
|
||||
elif intent_result.label == "info_query":
|
||||
reply = self._handle_info_query(state, user_input)
|
||||
elif intent_result.label == "high_intent":
|
||||
reply = self._handle_high_intent(state, user_input)
|
||||
else:
|
||||
reply = UNKNOWN_RESPONSE
|
||||
|
||||
state.add_message("assistant", reply)
|
||||
self.memory.save(state)
|
||||
return self._build_response(reply, state)
|
||||
|
||||
# ── Info query ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _handle_info_query(self, state: ConversationState, query: str) -> str:
|
||||
context = self.rag.get_context(query, top_k=3)
|
||||
system = AGENT_SYSTEM.format(context=context)
|
||||
history = state.get_history_for_llm(max_turns=6)
|
||||
try:
|
||||
return self.llm.chat(messages=history, system=system, temperature=0.3).strip()
|
||||
except Exception as e:
|
||||
logger.error(f"LLM info query failed: {e}")
|
||||
return "I'm having a quick issue — please try again in a moment."
|
||||
|
||||
# ── Lead capture ───────────────────────────────────────────────────────────
|
||||
|
||||
def _handle_high_intent(self, state: ConversationState, user_input: str) -> str:
|
||||
lead = state.lead
|
||||
|
||||
if not state.collecting_lead:
|
||||
state.collecting_lead = True
|
||||
|
||||
# Extract ALL fields from this message and merge into state
|
||||
extracted = extract_all_fields(user_input, self.llm)
|
||||
update_lead_state(lead, extracted, user_input)
|
||||
|
||||
logger.info(
|
||||
f"Lead state → name={lead.name} email={lead.email} platform={lead.platform}"
|
||||
)
|
||||
|
||||
# All 3 present → fire tool immediately
|
||||
if lead.is_complete():
|
||||
confirmation = mock_lead_capture(lead.name, lead.email, lead.platform)
|
||||
lead.captured = True
|
||||
lead.current_step = "done"
|
||||
return confirmation
|
||||
|
||||
# Ask only the next missing field
|
||||
if not lead.name:
|
||||
raw_email = regex_email(user_input)
|
||||
if raw_email and not is_valid_email(raw_email):
|
||||
return INVALID_EMAIL_MSG
|
||||
try:
|
||||
return self.llm.chat(
|
||||
messages=[{"role": "user", "content": user_input}],
|
||||
system=LEAD_ASK_NAME, temperature=0.5,
|
||||
).strip()
|
||||
except Exception:
|
||||
return "Let's get you set up! What's your name?"
|
||||
|
||||
if not lead.email:
|
||||
raw_email = regex_email(user_input) or (extracted.get("email") or "")
|
||||
if raw_email and not is_valid_email(raw_email):
|
||||
return INVALID_EMAIL_MSG
|
||||
try:
|
||||
return self.llm.chat(
|
||||
messages=[{"role": "user", "content": user_input}],
|
||||
system=LEAD_ASK_EMAIL.format(name=lead.name), temperature=0.5,
|
||||
).strip()
|
||||
except Exception:
|
||||
return f"Nice to meet you, {lead.name}! What's your email address?"
|
||||
|
||||
if not lead.platform:
|
||||
try:
|
||||
return self.llm.chat(
|
||||
messages=[{"role": "user", "content": user_input}],
|
||||
system=LEAD_ASK_PLATFORM.format(name=lead.name, email=lead.email),
|
||||
temperature=0.5,
|
||||
).strip()
|
||||
except Exception:
|
||||
return "Almost done! Which platform do you create content on?"
|
||||
|
||||
return "Something went wrong. Could you try again?"
|
||||
|
||||
@staticmethod
|
||||
def _build_response(reply: str, state: ConversationState) -> dict:
|
||||
return {
|
||||
"reply": reply,
|
||||
"intent": state.intent,
|
||||
"confidence": round(state.intent_confidence, 2),
|
||||
"lead_status": state.lead.to_dict(),
|
||||
"turn": state.turn_count,
|
||||
}
|
||||
|
||||
def reset_session(self, session_id: str):
|
||||
self.memory.reset(session_id)
|
||||
158
backend/app.py
Normal file
158
backend/app.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""
|
||||
app.py — FastAPI server for AutoStream AI Agent
|
||||
Run: uvicorn app:app --reload --port 8000
|
||||
"""
|
||||
|
||||
# ── Sanity check: block Flask from poisoning the import ───────────────────────
|
||||
import sys
|
||||
if "flask" in sys.modules:
|
||||
raise RuntimeError(
|
||||
"Flask is imported somewhere — this app runs on FastAPI only. "
|
||||
"Make sure you are NOT importing flask_app.py or any old Flask file."
|
||||
)
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
# ── Logging ────────────────────────────────────────────────────────────────────
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("autostream.app")
|
||||
|
||||
# ── Agent (lazy load) ──────────────────────────────────────────────────────────
|
||||
|
||||
_agent = None
|
||||
|
||||
def get_agent():
|
||||
global _agent
|
||||
if _agent is None:
|
||||
from agent import AutoStreamAgent
|
||||
_agent = AutoStreamAgent()
|
||||
logger.info("AutoStreamAgent ready.")
|
||||
return _agent
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("=" * 50)
|
||||
logger.info(" AutoStream FastAPI starting on port 8000")
|
||||
logger.info(" Swagger UI → http://localhost:8000/docs")
|
||||
logger.info("=" * 50)
|
||||
get_agent()
|
||||
yield
|
||||
logger.info("AutoStream API shut down.")
|
||||
|
||||
|
||||
# ── App ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(
|
||||
title="AutoStream AI Agent",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ── Rate limiter ───────────────────────────────────────────────────────────────
|
||||
|
||||
_buckets: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
def check_rate_limit(ip: str, window: int = 60, max_req: int = 30) -> bool:
|
||||
now = time.time()
|
||||
_buckets[ip] = [t for t in _buckets[ip] if now - t < window]
|
||||
if len(_buckets[ip]) >= max_req:
|
||||
return False
|
||||
_buckets[ip].append(now)
|
||||
return True
|
||||
|
||||
# ── Models ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
session_id: Optional[str] = None
|
||||
|
||||
class ResetRequest(BaseModel):
|
||||
session_id: str
|
||||
|
||||
_start = time.time()
|
||||
|
||||
# ── Routes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"uptime_s": round(time.time() - _start, 1),
|
||||
"sessions": get_agent().memory.active_sessions(),
|
||||
"port": 8000,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/chat")
|
||||
async def chat(req: ChatRequest, request: Request):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_rate_limit(ip):
|
||||
raise HTTPException(429, "Too many requests — slow down.")
|
||||
|
||||
message = (req.message or "").strip()
|
||||
if not message:
|
||||
raise HTTPException(422, "Message cannot be empty.")
|
||||
if len(message) > 2000:
|
||||
raise HTTPException(422, "Message too long (max 2000 chars).")
|
||||
|
||||
session_id = req.session_id or str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
result = get_agent().respond(session_id, message)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error [{session_id}]: {e}", exc_info=True)
|
||||
raise HTTPException(500, f"Agent error: {str(e)}")
|
||||
|
||||
logger.info(
|
||||
f"[{session_id}] intent={result['intent']} "
|
||||
f"conf={result['confidence']} turn={result['turn']}"
|
||||
)
|
||||
return {**result, "session_id": session_id}
|
||||
|
||||
|
||||
@app.post("/reset")
|
||||
async def reset(req: ResetRequest):
|
||||
get_agent().reset_session(req.session_id)
|
||||
return {"status": "reset", "session_id": req.session_id}
|
||||
|
||||
|
||||
@app.get("/leads")
|
||||
async def leads(secret: str = ""):
|
||||
from tools import get_all_leads
|
||||
return {"leads": get_all_leads()}
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _err(request: Request, exc: Exception):
|
||||
logger.error(f"Unhandled: {exc}", exc_info=True)
|
||||
return JSONResponse(500, {"detail": "Internal server error."})
|
||||
|
||||
|
||||
# ── Run directly ───────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
|
||||
225
backend/intent.py
Normal file
225
backend/intent.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""
|
||||
intent.py — Hybrid rule + LLM intent classification for AutoStream Agent
|
||||
Includes domain restriction filter as first gate.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("autostream.intent")
|
||||
|
||||
# ── Domain keywords — ONLY these topics are allowed ───────────────────────────
|
||||
|
||||
DOMAIN_KEYWORDS = [
|
||||
# Product
|
||||
"autostream", "auto stream",
|
||||
# Pricing / plans
|
||||
"price", "pricing", "cost", "plan", "basic", "pro", "subscription",
|
||||
"monthly", "per month", "how much", "dollar", "$",
|
||||
# Features
|
||||
"feature", "video", "resolution", "720p", "4k", "caption", "ai caption",
|
||||
"unlimited", "edit", "editing",
|
||||
# Policies
|
||||
"refund", "support", "policy", "policies", "trial", "free trial",
|
||||
# Actions
|
||||
"sign up", "signup", "subscribe", "buy", "purchase", "upgrade",
|
||||
"get started", "start", "try", "demo",
|
||||
# General help
|
||||
"help", "info", "information", "tell me", "what is", "how does",
|
||||
"platform", "youtube", "instagram", "tiktok", "twitter", "facebook",
|
||||
]
|
||||
|
||||
# Topics that are clearly off-domain — reject immediately
|
||||
OFF_DOMAIN_SIGNALS = [
|
||||
r"\bgirlfriend\b", r"\bboyfriend\b", r"\bjoke(s)?\b", r"\bweather\b",
|
||||
r"\brecipe(s)?\b", r"\bcook(ing)?\b", r"\bsport(s)?\b", r"\bfootball\b",
|
||||
r"\bpolitics\b", r"\bnews\b", r"\bmovie(s)?\b", r"\bsong(s)?\b",
|
||||
r"\bmeow\b", r"\bbark\b", r"\banimal(s)?\b", r"\bcat\b", r"\bdog\b",
|
||||
r"\bmath\b", r"\bcalculus\b", r"\bhistory\b", r"\bgeography\b",
|
||||
r"\bwrite (me|a) (poem|story|essay)\b", r"\btranslate\b",
|
||||
]
|
||||
|
||||
|
||||
def is_domain_relevant(text: str) -> bool:
|
||||
"""
|
||||
Returns True if the message is related to AutoStream domain.
|
||||
Returns False for off-topic input that should be rejected.
|
||||
|
||||
Logic:
|
||||
1. If explicit off-domain signal → reject
|
||||
2. If any domain keyword present → accept
|
||||
3. If very short (greeting-length) → accept (let intent handle it)
|
||||
4. Otherwise → reject
|
||||
"""
|
||||
lower = text.lower().strip()
|
||||
|
||||
# Very short messages (greetings) — let intent handle them
|
||||
if len(lower.split()) <= 3:
|
||||
return True
|
||||
|
||||
# Explicit off-domain signal → reject immediately
|
||||
for pattern in OFF_DOMAIN_SIGNALS:
|
||||
if re.search(pattern, lower):
|
||||
logger.debug(f"Domain rejected (off-domain signal): {text[:60]}")
|
||||
return False
|
||||
|
||||
# Domain keyword present → accept
|
||||
for kw in DOMAIN_KEYWORDS:
|
||||
if kw in lower:
|
||||
return True
|
||||
|
||||
# No domain keyword found in a longer message → reject
|
||||
logger.debug(f"Domain rejected (no keywords): {text[:60]}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Intent signal dictionaries ─────────────────────────────────────────────────
|
||||
|
||||
GREETING_SIGNALS = {
|
||||
"hi", "hello", "hey", "sup", "yo", "howdy", "hiya",
|
||||
"good morning", "good afternoon", "good evening",
|
||||
"what's up", "whats up", "greetings",
|
||||
}
|
||||
|
||||
HIGH_INTENT_PATTERNS = [
|
||||
r"\bwant(s)? to buy\b", r"\bsign me up\b", r"\bsign up\b",
|
||||
r"\bi want (pro|basic|plan)\b", r"\blet'?s go\b", r"\bget started\b",
|
||||
r"\bhow (do|can) i (start|subscribe|sign|buy|get)\b",
|
||||
r"\bpurchase\b", r"\bsubscribe\b", r"\bsounds good\b",
|
||||
r"\bi'?m in\b", r"\bready to\b", r"\bwant to try\b",
|
||||
r"\bwant to sign\b", r"\btake my money\b",
|
||||
r"\bwould like to sign\b", r"\bi want to upgrade\b",
|
||||
r"\bupgrade\b", r"\bbuy now\b", r"\bstart free trial\b",
|
||||
r"\bfree trial\b", r"\btrial\b", r"\bi want\b",
|
||||
r"\blet me (try|start|sign)\b", r"\bstart now\b",
|
||||
]
|
||||
|
||||
INFO_PATTERNS = [
|
||||
r"\bpric(e|ing|es)\b", r"\bplan(s)?\b", r"\bfeature(s)?\b",
|
||||
r"\bcost(s)?\b", r"\bhow much\b", r"\brefund\b", r"\bpolic(y|ies)\b",
|
||||
r"\bsupport\b", r"\bhow does\b", r"\bwhat (is|are|does)\b",
|
||||
r"\bcan you\b", r"\btell me\b", r"\bdo you (offer|have|support)\b",
|
||||
r"\bbasic plan\b", r"\bpro plan\b", r"\binclude(s)?\b",
|
||||
r"\bdifference\b", r"\bcompare\b", r"\binfo\b",
|
||||
r"\bresolution\b", r"\bvideo(s)?\b", r"\bcaption(s)?\b",
|
||||
r"\blimit(s)?\b", r"\bmonthly\b", r"\bwhat('?s| is) included\b",
|
||||
]
|
||||
|
||||
|
||||
# ── Result dataclass ───────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class IntentResult:
|
||||
label: str
|
||||
confidence: float
|
||||
method: str
|
||||
raw_llm: Optional[str] = None
|
||||
|
||||
|
||||
# ── Rule-based classifier ──────────────────────────────────────────────────────
|
||||
|
||||
def rule_classify(text: str) -> Optional[IntentResult]:
|
||||
lower = text.lower().strip()
|
||||
|
||||
# Greeting: only if short AND no product keywords
|
||||
has_product_kw = any(re.search(p, lower) for p in INFO_PATTERNS + HIGH_INTENT_PATTERNS)
|
||||
if not has_product_kw and len(lower) < 40:
|
||||
words = set(re.findall(r"\w+", lower))
|
||||
for sig in GREETING_SIGNALS:
|
||||
if sig in lower or words & set(sig.split()):
|
||||
return IntentResult(label="greeting", confidence=0.95, method="rule")
|
||||
|
||||
# High intent — check before info (more specific)
|
||||
for pattern in HIGH_INTENT_PATTERNS:
|
||||
if re.search(pattern, lower):
|
||||
return IntentResult(label="high_intent", confidence=0.95, method="rule")
|
||||
|
||||
# Info query
|
||||
info_matches = sum(1 for p in INFO_PATTERNS if re.search(p, lower))
|
||||
if info_matches >= 2:
|
||||
return IntentResult(label="info_query", confidence=0.90, method="rule")
|
||||
if info_matches == 1:
|
||||
return IntentResult(label="info_query", confidence=0.75, method="rule")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── LLM classifier ─────────────────────────────────────────────────────────────
|
||||
|
||||
INTENT_SYSTEM = """You are an intent classifier for AutoStream, a SaaS video-editing platform.
|
||||
|
||||
Classify the user message into exactly ONE of:
|
||||
greeting — ONLY casual hello/hi with NO product questions
|
||||
info_query — questions about features, pricing, plans, policies, refunds, support
|
||||
high_intent — wants to buy, sign up, subscribe, upgrade, start trial, or says "I want [plan]"
|
||||
unknown — gibberish, off-topic, unrelated to AutoStream
|
||||
|
||||
CRITICAL RULES:
|
||||
- "I want Pro plan" or "I want to try" → ALWAYS high_intent
|
||||
- "What is the price?" or "What features?" → ALWAYS info_query
|
||||
- greeting ONLY if purely social with zero product intent
|
||||
- unknown ONLY if genuinely off-topic
|
||||
|
||||
Respond with ONLY valid JSON, no markdown:
|
||||
{"label": "<one of the four>", "confidence": <0.0-1.0>, "reason": "<one sentence>"}"""
|
||||
|
||||
|
||||
def llm_classify(text: str, llm_client) -> IntentResult:
|
||||
try:
|
||||
raw = llm_client.chat(
|
||||
messages=[{"role": "user", "content": text}],
|
||||
system=INTENT_SYSTEM, temperature=0.0, max_tokens=80,
|
||||
)
|
||||
data = json.loads(raw.strip().replace("```json", "").replace("```", ""))
|
||||
label = data.get("label", "unknown")
|
||||
if label not in ("greeting", "info_query", "high_intent", "unknown"):
|
||||
label = "unknown"
|
||||
return IntentResult(
|
||||
label=label,
|
||||
confidence=float(data.get("confidence", 0.6)),
|
||||
method="llm", raw_llm=raw,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM intent classification failed: {e}")
|
||||
return IntentResult(label="unknown", confidence=0.3, method="llm_failed")
|
||||
|
||||
|
||||
# ── Hybrid classifier ──────────────────────────────────────────────────────────
|
||||
|
||||
def classify_intent(text: str, llm_client=None) -> IntentResult:
|
||||
text = text.strip()
|
||||
rule_result = rule_classify(text)
|
||||
|
||||
if rule_result and rule_result.confidence >= 0.90:
|
||||
logger.debug(f"Intent (rule): {rule_result.label} ({rule_result.confidence:.2f})")
|
||||
return rule_result
|
||||
|
||||
if llm_client:
|
||||
llm_result = llm_classify(text, llm_client)
|
||||
if rule_result and rule_result.label == llm_result.label:
|
||||
merged_conf = min(1.0, (rule_result.confidence + llm_result.confidence) / 2 + 0.1)
|
||||
return IntentResult(
|
||||
label=llm_result.label, confidence=merged_conf,
|
||||
method="hybrid", raw_llm=llm_result.raw_llm,
|
||||
)
|
||||
return llm_result
|
||||
|
||||
return rule_result or IntentResult(label="unknown", confidence=0.2, method="rule")
|
||||
|
||||
|
||||
# ── Context-aware correction ───────────────────────────────────────────────────
|
||||
|
||||
def maybe_correct_intent(
|
||||
current: IntentResult,
|
||||
history_intents: list[str],
|
||||
text: str,
|
||||
) -> IntentResult:
|
||||
"""Auto-correct to high_intent if we're mid lead-collection flow."""
|
||||
recent = history_intents[-3:]
|
||||
if current.label in ("unknown", "greeting") and recent.count("high_intent") >= 1:
|
||||
logger.info(f"Auto-correcting '{current.label}' → 'high_intent' (mid lead-flow).")
|
||||
return IntentResult(label="high_intent", confidence=0.65, method="auto_corrected")
|
||||
return current
|
||||
60
backend/knowledge_base.json
Normal file
60
backend/knowledge_base.json
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"company": "AutoStream",
|
||||
"description": "AutoStream is a SaaS platform that provides AI-powered automated video editing tools for content creators — helping YouTubers, TikTokers, and social media professionals save hours of editing time every week.",
|
||||
|
||||
"plans": {
|
||||
"basic": {
|
||||
"price": "$9/month",
|
||||
"features": [
|
||||
"Auto-cut silence & filler words",
|
||||
"Basic color grading",
|
||||
"720p export",
|
||||
"5 projects/month",
|
||||
"Email support"
|
||||
]
|
||||
},
|
||||
"pro": {
|
||||
"price": "$29/month",
|
||||
"features": [
|
||||
"Everything in Basic",
|
||||
"AI b-roll suggestions",
|
||||
"4K export",
|
||||
"Unlimited projects",
|
||||
"Custom branding & watermark removal",
|
||||
"Priority support",
|
||||
"AI thumbnail generator",
|
||||
"Multi-platform export (YouTube, TikTok, Instagram)"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"policies": {
|
||||
"refund": "30-day money-back guarantee, no questions asked.",
|
||||
"support": "Email support for Basic plan; priority live chat + email for Pro plan. Response time: 24h (Basic), 4h (Pro).",
|
||||
"cancellation": "Cancel anytime from your dashboard. No lock-in contracts.",
|
||||
"free_trial": "7-day free trial on Pro plan, no credit card required."
|
||||
},
|
||||
|
||||
"faq": [
|
||||
{
|
||||
"question": "What platforms does AutoStream support?",
|
||||
"answer": "AutoStream exports directly to YouTube, TikTok, Instagram Reels, and Facebook. More platforms coming soon."
|
||||
},
|
||||
{
|
||||
"question": "Do I need video editing experience?",
|
||||
"answer": "No experience needed. AutoStream handles the technical editing automatically — just upload your raw footage."
|
||||
},
|
||||
{
|
||||
"question": "Can I cancel anytime?",
|
||||
"answer": "Yes! Cancel anytime from your dashboard with no fees or penalties."
|
||||
},
|
||||
{
|
||||
"question": "Is there a free trial?",
|
||||
"answer": "Yes — the Pro plan comes with a 7-day free trial, no credit card required."
|
||||
},
|
||||
{
|
||||
"question": "What video formats are supported?",
|
||||
"answer": "We support MP4, MOV, AVI, and MKV files up to 10GB per upload."
|
||||
}
|
||||
]
|
||||
}
|
||||
131
backend/memory.py
Normal file
131
backend/memory.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""
|
||||
memory.py — Conversation state management for AutoStream Agent
|
||||
Fixed: strict lead step tracking, no skipping, no re-asking collected fields
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger("autostream.memory")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeadData:
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
platform: Optional[str] = None
|
||||
captured: bool = False
|
||||
|
||||
# Track which step we're on: "name" | "email" | "platform" | "done"
|
||||
current_step: str = "name"
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
return bool(self.name and self.email and self.platform)
|
||||
|
||||
def next_missing_field(self) -> Optional[str]:
|
||||
"""Always returns fields in strict order: name → email → platform."""
|
||||
if not self.name:
|
||||
return "name"
|
||||
if not self.email:
|
||||
return "email"
|
||||
if not self.platform:
|
||||
return "platform"
|
||||
return None
|
||||
|
||||
def advance_step(self):
|
||||
"""Move to next step after a field is collected."""
|
||||
missing = self.next_missing_field()
|
||||
self.current_step = missing if missing else "done"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"email": self.email,
|
||||
"platform": self.platform,
|
||||
"captured": self.captured,
|
||||
"current_step": self.current_step,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationState:
|
||||
session_id: str
|
||||
messages: list = field(default_factory=list)
|
||||
intent: str = "unknown"
|
||||
intent_confidence: float = 0.0
|
||||
collecting_lead: bool = False
|
||||
lead: LeadData = field(default_factory=LeadData)
|
||||
turn_count: int = 0
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_active: float = field(default_factory=time.time)
|
||||
intent_corrections: int = 0
|
||||
|
||||
def add_message(self, role: str, content: str):
|
||||
self.messages.append({
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": time.time(),
|
||||
"intent": self.intent if role == "user" else None,
|
||||
})
|
||||
self.last_active = time.time()
|
||||
if role == "user":
|
||||
self.turn_count += 1
|
||||
|
||||
def get_history_for_llm(self, max_turns: int = 10) -> list[dict]:
|
||||
return [
|
||||
{"role": m["role"], "content": m["content"]}
|
||||
for m in self.messages[-max_turns * 2:]
|
||||
]
|
||||
|
||||
def get_intent_history(self) -> list[str]:
|
||||
return [
|
||||
m["intent"] for m in self.messages
|
||||
if m["role"] == "user" and m.get("intent")
|
||||
]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"intent": self.intent,
|
||||
"intent_confidence": self.intent_confidence,
|
||||
"collecting_lead": self.collecting_lead,
|
||||
"lead": self.lead.to_dict(),
|
||||
"turn_count": self.turn_count,
|
||||
}
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
SESSION_TTL = 3600 # 1 hour
|
||||
|
||||
def __init__(self):
|
||||
self._store: dict[str, ConversationState] = {}
|
||||
logger.info("MemoryStore initialized.")
|
||||
|
||||
def get(self, session_id: str) -> ConversationState:
|
||||
self._evict_expired()
|
||||
if session_id not in self._store:
|
||||
logger.debug(f"New session: {session_id}")
|
||||
self._store[session_id] = ConversationState(session_id=session_id)
|
||||
return self._store[session_id]
|
||||
|
||||
def save(self, state: ConversationState):
|
||||
self._store[state.session_id] = state
|
||||
|
||||
def reset(self, session_id: str):
|
||||
if session_id in self._store:
|
||||
del self._store[session_id]
|
||||
logger.info(f"Session reset: {session_id}")
|
||||
|
||||
def _evict_expired(self):
|
||||
now = time.time()
|
||||
expired = [
|
||||
sid for sid, s in self._store.items()
|
||||
if now - s.last_active > self.SESSION_TTL
|
||||
]
|
||||
for sid in expired:
|
||||
del self._store[sid]
|
||||
|
||||
def active_sessions(self) -> int:
|
||||
return len(self._store)
|
||||
156
backend/rag.py
Normal file
156
backend/rag.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""
|
||||
rag.py — Retrieval-Augmented Generation pipeline for AutoStream Agent
|
||||
Loads knowledge_base.json, chunks it, embeds with sentence-transformers,
|
||||
indexes with FAISS, and retrieves top-k relevant chunks at query time.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("autostream.rag")
|
||||
|
||||
# ── Optional heavy imports (graceful fallback) ─────────────────────────────────
|
||||
try:
|
||||
import numpy as np
|
||||
import faiss
|
||||
from sentence_transformers import SentenceTransformer
|
||||
_FAISS_AVAILABLE = True
|
||||
except ImportError:
|
||||
_FAISS_AVAILABLE = False
|
||||
logger.warning("FAISS / sentence-transformers not installed. Falling back to keyword RAG.")
|
||||
|
||||
|
||||
KB_PATH = Path(__file__).parent / "knowledge_base.json"
|
||||
|
||||
|
||||
# ── Knowledge base loader ──────────────────────────────────────────────────────
|
||||
|
||||
def load_knowledge_base(path: Path = KB_PATH) -> dict:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"knowledge_base.json not found at {path}")
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def chunk_knowledge_base(data: dict) -> list[dict]:
|
||||
"""
|
||||
Convert the JSON knowledge base into a flat list of text chunks,
|
||||
each with a source label.
|
||||
"""
|
||||
chunks = []
|
||||
|
||||
# Company overview
|
||||
chunks.append({
|
||||
"id": "overview",
|
||||
"source": "Company Overview",
|
||||
"text": f"{data['company']} — {data['description']}",
|
||||
})
|
||||
|
||||
# Plans
|
||||
for plan_key, plan in data.get("plans", {}).items():
|
||||
features = ", ".join(plan.get("features", []))
|
||||
chunks.append({
|
||||
"id": f"plan_{plan_key}",
|
||||
"source": f"{plan_key.title()} Plan",
|
||||
"text": (
|
||||
f"{plan_key.title()} Plan costs {plan['price']}. "
|
||||
f"Features include: {features}."
|
||||
),
|
||||
})
|
||||
|
||||
# Policies
|
||||
for policy_key, policy_val in data.get("policies", {}).items():
|
||||
chunks.append({
|
||||
"id": f"policy_{policy_key}",
|
||||
"source": f"Policy: {policy_key.replace('_', ' ').title()}",
|
||||
"text": f"{policy_key.replace('_', ' ').title()}: {policy_val}",
|
||||
})
|
||||
|
||||
# FAQ (if present)
|
||||
for faq in data.get("faq", []):
|
||||
chunks.append({
|
||||
"id": f"faq_{len(chunks)}",
|
||||
"source": "FAQ",
|
||||
"text": f"Q: {faq['question']} A: {faq['answer']}",
|
||||
})
|
||||
|
||||
logger.info(f"Knowledge base chunked into {len(chunks)} segments.")
|
||||
return chunks
|
||||
|
||||
|
||||
# ── FAISS-backed retriever ─────────────────────────────────────────────────────
|
||||
|
||||
class FAISSRetriever:
|
||||
def __init__(self, chunks: list[dict], model_name: str = "all-MiniLM-L6-v2"):
|
||||
self.chunks = chunks
|
||||
self.model = SentenceTransformer(model_name)
|
||||
texts = [c["text"] for c in chunks]
|
||||
embeddings = self.model.encode(texts, convert_to_numpy=True)
|
||||
self.index = faiss.IndexFlatL2(embeddings.shape[1])
|
||||
self.index.add(embeddings.astype("float32"))
|
||||
logger.info(f"FAISS index built with {len(chunks)} chunks.")
|
||||
|
||||
def retrieve(self, query: str, top_k: int = 3) -> list[dict]:
|
||||
q_vec = self.model.encode([query], convert_to_numpy=True).astype("float32")
|
||||
distances, indices = self.index.search(q_vec, top_k)
|
||||
results = []
|
||||
for dist, idx in zip(distances[0], indices[0]):
|
||||
if idx < len(self.chunks):
|
||||
results.append({**self.chunks[idx], "score": float(dist)})
|
||||
logger.debug(f"RAG retrieved {len(results)} chunks for: '{query}'")
|
||||
return results
|
||||
|
||||
|
||||
# ── Keyword fallback retriever ─────────────────────────────────────────────────
|
||||
|
||||
class KeywordRetriever:
|
||||
def __init__(self, chunks: list[dict]):
|
||||
self.chunks = chunks
|
||||
|
||||
def retrieve(self, query: str, top_k: int = 3) -> list[dict]:
|
||||
tokens = set(re.findall(r"\w+", query.lower()))
|
||||
scored = []
|
||||
for chunk in self.chunks:
|
||||
chunk_tokens = set(re.findall(r"\w+", chunk["text"].lower()))
|
||||
score = len(tokens & chunk_tokens)
|
||||
scored.append((score, chunk))
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
results = [c for _, c in scored[:top_k]]
|
||||
logger.debug(f"Keyword RAG retrieved {len(results)} chunks for: '{query}'")
|
||||
return results
|
||||
|
||||
|
||||
# ── Public RAG interface ───────────────────────────────────────────────────────
|
||||
|
||||
class RAGPipeline:
|
||||
def __init__(self):
|
||||
data = load_knowledge_base()
|
||||
self.raw_data = data
|
||||
self.chunks = chunk_knowledge_base(data)
|
||||
|
||||
if _FAISS_AVAILABLE:
|
||||
try:
|
||||
self.retriever = FAISSRetriever(self.chunks)
|
||||
logger.info("Using FAISS semantic retriever.")
|
||||
except Exception as e:
|
||||
logger.warning(f"FAISS init failed ({e}), falling back to keyword.")
|
||||
self.retriever = KeywordRetriever(self.chunks)
|
||||
else:
|
||||
self.retriever = KeywordRetriever(self.chunks)
|
||||
logger.info("Using keyword-based retriever (FAISS not available).")
|
||||
|
||||
def get_context(self, query: str, top_k: int = 3) -> str:
|
||||
"""Return a formatted context string for the LLM prompt."""
|
||||
hits = self.retriever.retrieve(query, top_k=top_k)
|
||||
if not hits:
|
||||
return "No specific information found in the knowledge base."
|
||||
parts = [f"[{h['source']}] {h['text']}" for h in hits]
|
||||
return "\n".join(parts)
|
||||
|
||||
def full_context(self) -> str:
|
||||
"""Return ALL chunks as a single context string (for grounding)."""
|
||||
return "\n".join(c["text"] for c in self.chunks)
|
||||
64
backend/tools.py
Normal file
64
backend/tools.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
tools.py — Tool functions for AutoStream Agent
|
||||
Includes lead capture, email validation, and logging.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("autostream.tools")
|
||||
|
||||
# ── Email validator ────────────────────────────────────────────────────────────
|
||||
|
||||
EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$")
|
||||
|
||||
def is_valid_email(value: str) -> bool:
|
||||
return bool(EMAIL_RE.match(value.strip()))
|
||||
|
||||
|
||||
# ── Lead capture ───────────────────────────────────────────────────────────────
|
||||
|
||||
_lead_log: list[dict] = [] # in-memory store; swap for DB in production
|
||||
|
||||
def mock_lead_capture(name: str, email: str, platform: str) -> str:
|
||||
"""
|
||||
Simulates saving a lead to CRM / database.
|
||||
Returns a confirmation string.
|
||||
"""
|
||||
entry = {
|
||||
"name": name,
|
||||
"email": email,
|
||||
"platform": platform,
|
||||
"captured_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
}
|
||||
_lead_log.append(entry)
|
||||
logger.info(f"[LEAD CAPTURED] {entry}")
|
||||
print(f"\n✅ Lead captured: {name} | {email} | {platform}\n")
|
||||
return (
|
||||
f"🎉 You're all set, {name}! "
|
||||
f"Our team will reach out at {email} shortly. "
|
||||
f"Can't wait to supercharge your {platform} content!"
|
||||
)
|
||||
|
||||
|
||||
def get_all_leads() -> list[dict]:
|
||||
"""Return all captured leads (for admin/debug use)."""
|
||||
return list(_lead_log)
|
||||
|
||||
|
||||
# ── Platform normaliser ────────────────────────────────────────────────────────
|
||||
|
||||
PLATFORM_ALIASES = {
|
||||
"yt": "YouTube", "youtube": "YouTube",
|
||||
"ig": "Instagram", "insta": "Instagram", "instagram": "Instagram",
|
||||
"tt": "TikTok", "tiktok": "TikTok",
|
||||
"tw": "Twitter", "twitter": "Twitter", "x": "Twitter/X",
|
||||
"fb": "Facebook", "facebook": "Facebook",
|
||||
"li": "LinkedIn", "linkedin": "LinkedIn",
|
||||
"twitch": "Twitch",
|
||||
}
|
||||
|
||||
def normalize_platform(value: str) -> str:
|
||||
key = value.strip().lower().rstrip(".")
|
||||
return PLATFORM_ALIASES.get(key, value.strip().title())
|
||||
538
frontend/index.html
Normal file
538
frontend/index.html
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>AutoStream AI Agent</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
|
||||
/* ═══════════════════════════════
|
||||
LIGHT THEME (default)
|
||||
═══════════════════════════════ */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--brand: #6C47FF;
|
||||
--brand-light: #7C5CFF;
|
||||
--brand-glow: rgba(108,71,255,0.10);
|
||||
--bg: #F5F6FA;
|
||||
--surface: #FFFFFF;
|
||||
--surface2: #F0F1F7;
|
||||
--surface3: #E8E9F2;
|
||||
--border: rgba(0,0,0,0.09);
|
||||
--border2: rgba(108,71,255,0.30);
|
||||
--text: #16152A;
|
||||
--text-dim: #6B6A85;
|
||||
--text-muted: #AAAAC0;
|
||||
--user-bg: #6C47FF;
|
||||
--user-text: #fff;
|
||||
--bot-bg: #FFFFFF;
|
||||
--bot-text: #16152A;
|
||||
--header-bg: rgba(255,255,255,0.94);
|
||||
--input-bg: #FFFFFF;
|
||||
--input-zone-bg:rgba(245,246,250,0.92);
|
||||
--scrollbar: rgba(0,0,0,0.12);
|
||||
--green: #16A34A;
|
||||
--shadow-bubble:0 2px 10px rgba(0,0,0,0.07);
|
||||
--shadow-user: 0 2px 14px rgba(108,71,255,0.28);
|
||||
--grid-color: rgba(108,71,255,0.04);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════
|
||||
DARK THEME
|
||||
═══════════════════════════════ */
|
||||
[data-theme="dark"] {
|
||||
--brand: #7C5CFF;
|
||||
--brand-light: #9B80FF;
|
||||
--brand-glow: rgba(108,71,255,0.18);
|
||||
--bg: #0D0D12;
|
||||
--surface: #16161E;
|
||||
--surface2: #1E1E2A;
|
||||
--surface3: #252533;
|
||||
--border: rgba(255,255,255,0.07);
|
||||
--border2: rgba(124,92,255,0.30);
|
||||
--text: #F0EFF8;
|
||||
--text-dim: #8A8AA0;
|
||||
--text-muted: #4A4A60;
|
||||
--user-bg: #6C47FF;
|
||||
--user-text: #fff;
|
||||
--bot-bg: #1E1E2A;
|
||||
--bot-text: #F0EFF8;
|
||||
--header-bg: rgba(13,13,18,0.94);
|
||||
--input-bg: #1E1E2A;
|
||||
--input-zone-bg:rgba(13,13,18,0.88);
|
||||
--scrollbar: rgba(255,255,255,0.10);
|
||||
--green: #22C55E;
|
||||
--shadow-bubble:0 2px 12px rgba(0,0,0,0.35);
|
||||
--shadow-user: 0 2px 16px rgba(108,71,255,0.40);
|
||||
--grid-color: rgba(108,71,255,0.03);
|
||||
}
|
||||
|
||||
/* ── Reset ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: 'Sora', sans-serif;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--bg);
|
||||
background-image:
|
||||
linear-gradient(var(--grid-color) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* ── HEADER ── */
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 22px;
|
||||
height: 62px;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 50;
|
||||
transition: background 0.3s, border-color 0.3s;
|
||||
}
|
||||
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.avatar {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #6C47FF, #A78BFA);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 19px;
|
||||
box-shadow: 0 0 14px rgba(108,71,255,0.35);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.header-info { display: flex; flex-direction: column; gap: 3px; }
|
||||
|
||||
.header-name {
|
||||
font-size: 14px; font-weight: 600;
|
||||
color: var(--text); letter-spacing: 0.01em; line-height: 1;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-size: 11px; color: var(--green); font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 6px var(--green);
|
||||
animation: pulse-dot 2.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-dot {
|
||||
0%,100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Header right buttons */
|
||||
.header-right { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 17px;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
background: var(--surface3);
|
||||
color: var(--brand);
|
||||
border-color: var(--border2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Show sun in dark, moon in light */
|
||||
[data-theme="light"] svg.icon-sun { display: none; }
|
||||
[data-theme="light"] svg.icon-moon { display: block; }
|
||||
[data-theme="dark"] svg.icon-moon { display: none; }
|
||||
[data-theme="dark"] svg.icon-sun { display: block; }
|
||||
|
||||
.new-chat-btn {
|
||||
font-family: 'Sora', sans-serif;
|
||||
font-size: 12px; font-weight: 500;
|
||||
color: var(--brand);
|
||||
background: var(--brand-glow);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 8px;
|
||||
padding: 7px 15px; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.new-chat-btn:hover {
|
||||
background: rgba(108,71,255,0.18);
|
||||
border-color: var(--brand);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── CHAT BOX ── */
|
||||
#chat-box {
|
||||
flex: 1 1 0;
|
||||
overflow-y: auto;
|
||||
padding: 26px 16px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
#chat-box::-webkit-scrollbar { width: 4px; }
|
||||
#chat-box::-webkit-scrollbar-track { background: transparent; }
|
||||
#chat-box::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 4px; }
|
||||
|
||||
.msg-row {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: msg-in 0.28s cubic-bezier(.2,0,.3,1) both;
|
||||
}
|
||||
@keyframes msg-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.msg-row.bot { align-items: flex-start; }
|
||||
.msg-row.user { align-items: flex-end; }
|
||||
|
||||
.msg-sender {
|
||||
font-size: 10px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.12em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.msg-row.bot .msg-sender { color: var(--brand); padding-left: 4px; }
|
||||
.msg-row.user .msg-sender { color: var(--text-dim); padding-right: 4px; }
|
||||
|
||||
.bubble {
|
||||
padding: 13px 17px;
|
||||
border-radius: 16px;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.68;
|
||||
word-break: break-word;
|
||||
max-width: 80%;
|
||||
transition: background 0.3s, color 0.3s, border-color 0.3s;
|
||||
}
|
||||
.bubble strong { font-weight: 600; color: var(--brand); }
|
||||
|
||||
.msg-row.bot .bubble {
|
||||
background: var(--bot-bg);
|
||||
color: var(--bot-text);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 4px;
|
||||
box-shadow: var(--shadow-bubble);
|
||||
}
|
||||
.msg-row.user .bubble {
|
||||
background: var(--user-bg);
|
||||
color: var(--user-text);
|
||||
border-bottom-right-radius: 4px;
|
||||
box-shadow: var(--shadow-user);
|
||||
}
|
||||
.msg-row.error .bubble {
|
||||
background: rgba(239,68,68,0.08);
|
||||
border-color: rgba(239,68,68,0.2);
|
||||
color: #DC2626;
|
||||
}
|
||||
|
||||
/* ── QUICK ACTIONS ── */
|
||||
.quick-actions {
|
||||
width: 100%; max-width: 720px;
|
||||
margin: -4px auto 0;
|
||||
display: flex; flex-wrap: wrap; gap: 7px;
|
||||
padding-left: 4px;
|
||||
animation: msg-in 0.35s 0.12s cubic-bezier(.2,0,.3,1) both;
|
||||
}
|
||||
.qa-btn {
|
||||
font-family: 'Sora', sans-serif;
|
||||
font-size: 12.5px; font-weight: 500;
|
||||
color: var(--brand);
|
||||
background: var(--surface);
|
||||
border: 1.5px solid var(--border2);
|
||||
border-radius: 8px;
|
||||
padding: 7px 15px; cursor: pointer;
|
||||
transition: all 0.17s; white-space: nowrap;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
.qa-btn:hover {
|
||||
background: var(--brand-glow);
|
||||
border-color: var(--brand);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 10px rgba(108,71,255,0.15);
|
||||
}
|
||||
.qa-btn:active { transform: translateY(0); }
|
||||
|
||||
/* ── TYPING INDICATOR ── */
|
||||
#typing-indicator { display: none; padding: 0 16px 4px; }
|
||||
#typing-indicator.visible { display: block; }
|
||||
|
||||
.typing-wrap {
|
||||
width: 100%; max-width: 720px;
|
||||
margin: 0 auto;
|
||||
display: flex; flex-direction: column; align-items: flex-start;
|
||||
}
|
||||
.typing-sender {
|
||||
font-size: 10px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.12em;
|
||||
color: var(--brand); padding-left: 4px; margin-bottom: 5px;
|
||||
}
|
||||
.typing-bubble {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: var(--bot-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px; border-bottom-left-radius: 4px;
|
||||
padding: 13px 18px;
|
||||
box-shadow: var(--shadow-bubble);
|
||||
transition: background 0.3s, border-color 0.3s;
|
||||
}
|
||||
.typing-dots { display: flex; gap: 5px; align-items: center; }
|
||||
.typing-dots span {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--brand);
|
||||
animation: bounce-dot 1.2s ease-in-out infinite; opacity: 0.6;
|
||||
}
|
||||
.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes bounce-dot {
|
||||
0%,80%,100% { transform: translateY(0); opacity: 0.35; }
|
||||
40% { transform: translateY(-6px); opacity: 1; }
|
||||
}
|
||||
.typing-text {
|
||||
font-size: 13px; font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.typing-text::after {
|
||||
content: '▋';
|
||||
display: inline-block;
|
||||
animation: blink-c 0.85s steps(1) infinite;
|
||||
color: var(--brand); margin-left: 2px;
|
||||
}
|
||||
@keyframes blink-c { 0%,100% { opacity:1; } 50% { opacity:0; } }
|
||||
|
||||
/* ── INPUT AREA ── */
|
||||
.input-zone {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 16px 20px;
|
||||
background: var(--input-zone-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1px solid var(--border);
|
||||
transition: background 0.3s, border-color 0.3s;
|
||||
}
|
||||
.input-inner { max-width: 720px; margin: 0 auto; }
|
||||
|
||||
.input-card {
|
||||
background: var(--input-bg);
|
||||
border-radius: 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.3s;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.input-card.focused {
|
||||
border-color: var(--border2);
|
||||
box-shadow: 0 0 0 3px rgba(108,71,255,0.10), 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
#user-input {
|
||||
width: 100%; border: none; outline: none;
|
||||
background: transparent;
|
||||
font-family: 'Sora', sans-serif;
|
||||
font-size: 14.5px; color: var(--text);
|
||||
line-height: 1.6;
|
||||
padding: 15px 17px 9px;
|
||||
resize: none; min-height: 26px; max-height: 160px;
|
||||
overflow-y: hidden; display: block;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
#user-input::placeholder { color: var(--text-muted); font-size: 13.5px; }
|
||||
|
||||
.input-bar {
|
||||
display: flex; align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 11px 11px;
|
||||
}
|
||||
.input-hint {
|
||||
font-size: 10.5px; color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 37px; height: 37px; border-radius: 10px;
|
||||
border: none; background: var(--brand); color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.18s;
|
||||
box-shadow: 0 2px 10px rgba(108,71,255,0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.send-btn:hover {
|
||||
background: var(--brand-light);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(108,71,255,0.45);
|
||||
}
|
||||
.send-btn:active { transform: translateY(0) scale(0.95); }
|
||||
.send-btn:disabled { background: var(--surface3); box-shadow: none; cursor: not-allowed; transform: none; }
|
||||
|
||||
@keyframes shake {
|
||||
0%,100% { transform:translateX(0); }
|
||||
20% { transform:translateX(-6px); } 40% { transform:translateX(6px); }
|
||||
60% { transform:translateX(-4px); } 80% { transform:translateX(4px); }
|
||||
}
|
||||
.shake { animation: shake 0.3s ease; }
|
||||
|
||||
/* ── RESPONSIVE ── */
|
||||
@media (max-width: 600px) {
|
||||
.header { padding: 0 12px; height: 56px; }
|
||||
#chat-box { padding: 14px 10px 8px; gap: 12px; }
|
||||
.bubble { font-size: 14px; max-width: 90%; }
|
||||
.input-zone { padding: 8px 10px 14px; }
|
||||
#user-input { font-size: 14px; padding: 12px 13px 8px; }
|
||||
.input-hint { display: none; }
|
||||
}
|
||||
|
||||
/* Help list items in welcome message */
|
||||
.help-item {
|
||||
display: block;
|
||||
padding: 3px 0 3px 14px;
|
||||
position: relative;
|
||||
color: var(--text-dim);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.help-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px; height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- HEADER -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="avatar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polygon points="10 8 16 12 10 16 10 8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
<div class="header-name">AutoStream AI Agent</div>
|
||||
<div class="header-status">
|
||||
<span class="status-dot"></span>
|
||||
Online · Always available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- Theme toggle -->
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
|
||||
<!-- Moon = switch to dark, shown in light mode -->
|
||||
<svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
<!-- Sun = switch to light, shown in dark mode -->
|
||||
<svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="new-chat-btn" onclick="newChat()">+ New Chat</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- MESSAGES -->
|
||||
<div id="chat-box"></div>
|
||||
|
||||
<!-- TYPING INDICATOR -->
|
||||
<div id="typing-indicator">
|
||||
<div class="typing-wrap">
|
||||
<div class="typing-sender">AutoStream AI</div>
|
||||
<div class="typing-bubble">
|
||||
<div class="typing-dots"><span></span><span></span><span></span></div>
|
||||
<span class="typing-text" id="typingText">Thinking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- INPUT -->
|
||||
<div class="input-zone">
|
||||
<div class="input-inner">
|
||||
<div class="input-card" id="inputCard">
|
||||
<textarea
|
||||
id="user-input"
|
||||
placeholder="Ask about pricing, features, or plans..."
|
||||
rows="1"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
></textarea>
|
||||
<div class="input-bar">
|
||||
<span class="input-hint">Enter ↵ to send · Shift+Enter for newline</span>
|
||||
<button class="send-btn" id="send-btn" onclick="sendMessage()" title="Send">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="19" x2="12" y2="5"/>
|
||||
<polyline points="5 12 12 5 19 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
<script>
|
||||
/* Theme toggle — lives in HTML so it works even without script.js */
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const next = html.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
|
||||
html.setAttribute('data-theme', next);
|
||||
try { localStorage.setItem('autostream_theme', next); } catch {}
|
||||
}
|
||||
|
||||
/* Restore saved preference */
|
||||
(function () {
|
||||
try {
|
||||
const saved = localStorage.getItem('autostream_theme');
|
||||
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
||||
} catch {}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
246
frontend/script.js
Normal file
246
frontend/script.js
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/* ═══════════════════════════════════════════
|
||||
AutoStream AI Agent — script.js
|
||||
Backend: FastAPI on port 8000
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
const STORAGE_KEY = 'autostream_sessions';
|
||||
const API_BASE = 'http://127.0.0.1:8000';
|
||||
const API_CHAT = `${API_BASE}/chat`;
|
||||
const API_RESET = `${API_BASE}/reset`;
|
||||
|
||||
let currentChatId = null;
|
||||
let conversationHistory = [];
|
||||
let isStreaming = false;
|
||||
let sessionId = 'session_' + Math.random().toString(36).slice(2, 10);
|
||||
|
||||
const chatBox = document.getElementById('chat-box');
|
||||
const userInput = document.getElementById('user-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const typingEl = document.getElementById('typing-indicator');
|
||||
const typingText = document.getElementById('typingText');
|
||||
const inputCard = document.getElementById('inputCard');
|
||||
|
||||
// ── Welcome ───────────────────────────────
|
||||
function showWelcome() {
|
||||
if (!chatBox) return;
|
||||
chatBox.innerHTML = '';
|
||||
appendBotMessage(
|
||||
`Hello. I'm <strong>AutoStream AI</strong>, your dedicated support assistant.<br><br>` +
|
||||
`I can assist you with:<br>` +
|
||||
`<span class="help-item">Pricing and plan details</span>` +
|
||||
`<span class="help-item">Product features and capabilities</span>` +
|
||||
`<span class="help-item">Getting started with AutoStream</span>` +
|
||||
`<span class="help-item">Account sign-up and onboarding</span><br>` +
|
||||
`How can I help you today?`
|
||||
);
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.className = 'quick-actions';
|
||||
btnRow.id = 'quickActions';
|
||||
btnRow.innerHTML = `
|
||||
<button class="qa-btn" onclick="quickSend('What is the pricing?')">Pricing</button>
|
||||
<button class="qa-btn" onclick="quickSend('Tell me about the Pro plan')">Pro Plan</button>
|
||||
<button class="qa-btn" onclick="quickSend('What features does AutoStream offer?')">Features</button>
|
||||
<button class="qa-btn" onclick="quickSend('I would like to sign up for AutoStream')">Get Started</button>
|
||||
`;
|
||||
chatBox.appendChild(btnRow);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function quickSend(text) {
|
||||
const qa = document.getElementById('quickActions');
|
||||
if (qa) qa.remove();
|
||||
sendMessage(text);
|
||||
}
|
||||
|
||||
// ── New Chat ──────────────────────────────
|
||||
function newChat() {
|
||||
fetch(API_RESET, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId })
|
||||
}).catch(() => {});
|
||||
|
||||
sessionId = 'session_' + Math.random().toString(36).slice(2, 10);
|
||||
currentChatId = null;
|
||||
conversationHistory = [];
|
||||
|
||||
showWelcome();
|
||||
if (userInput) { userInput.value = ''; autoResize(); userInput.focus(); }
|
||||
}
|
||||
|
||||
// ── Send Message ──────────────────────────
|
||||
async function sendMessage(overrideText) {
|
||||
if (isStreaming) return;
|
||||
const text = overrideText || (userInput ? userInput.value.trim() : '');
|
||||
if (!text) {
|
||||
if (inputCard) {
|
||||
inputCard.classList.add('shake');
|
||||
setTimeout(() => inputCard.classList.remove('shake'), 320);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const qa = document.getElementById('quickActions');
|
||||
if (qa) qa.remove();
|
||||
if (userInput && !overrideText) { userInput.value = ''; autoResize(); }
|
||||
|
||||
appendUserMessage(text);
|
||||
if (!currentChatId) currentChatId = Date.now().toString();
|
||||
conversationHistory.push({ role: 'user', content: text });
|
||||
|
||||
isStreaming = true;
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
showTyping('Thinking');
|
||||
|
||||
try {
|
||||
await callBackend(text);
|
||||
} catch (err) {
|
||||
hideTyping();
|
||||
appendBotMessage(
|
||||
`⚠️ Could not connect to backend. Make sure FastAPI is running on port 8000.<br>` +
|
||||
`<small style="color:var(--text-muted)">Run: <code>uvicorn app:app --reload --port 8000</code></small>`,
|
||||
true
|
||||
);
|
||||
console.error('AutoStream error:', err);
|
||||
}
|
||||
|
||||
isStreaming = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
}
|
||||
|
||||
// ── Backend Call ──────────────────────────
|
||||
async function callBackend(userText) {
|
||||
const labels = ['Thinking', 'Searching knowledge base', 'Crafting response'];
|
||||
let li = 0;
|
||||
const labelTimer = setInterval(() => {
|
||||
li = (li + 1) % labels.length;
|
||||
if (typingText) typingText.textContent = labels[li];
|
||||
}, 1400);
|
||||
|
||||
// FastAPI expects: { message: str, session_id: str }
|
||||
const response = await fetch(API_CHAT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: userText, session_id: sessionId })
|
||||
});
|
||||
|
||||
clearInterval(labelTimer);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `API error ${response.status}`);
|
||||
}
|
||||
|
||||
// FastAPI returns: { reply, intent, confidence, lead_status, turn, session_id }
|
||||
const data = await response.json();
|
||||
const reply = data.reply || '(no response)';
|
||||
const intent = data.intent;
|
||||
const confidence = data.confidence;
|
||||
const lead = data.lead_status || {};
|
||||
|
||||
hideTyping();
|
||||
|
||||
const formattedReply = escHtml(reply).replace(/\n/g, '<br>');
|
||||
appendBotMessage(formattedReply, false, { intent, confidence, lead });
|
||||
|
||||
conversationHistory.push({ role: 'assistant', content: reply });
|
||||
|
||||
try {
|
||||
const sessions = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
||||
sessions[currentChatId] = {
|
||||
title: userText.slice(0, 40),
|
||||
timestamp: Date.now(),
|
||||
history: conversationHistory
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Append Messages ───────────────────────
|
||||
function appendBotMessage(html, isError = false, meta = null) {
|
||||
if (!chatBox) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = `msg-row bot${isError ? ' error' : ''}`;
|
||||
|
||||
let badgeHtml = '';
|
||||
if (meta && meta.intent && !isError) {
|
||||
const colors = {
|
||||
greeting: '#22c55e',
|
||||
info_query: '#60b8ff',
|
||||
high_intent: '#ffb340',
|
||||
unknown: '#f05252'
|
||||
};
|
||||
const col = colors[meta.intent] || '#888';
|
||||
const conf = meta.confidence ? ` · ${Math.round(meta.confidence * 100)}%` : '';
|
||||
|
||||
// Lead slot dots
|
||||
let leadDots = '';
|
||||
if (meta.intent === 'high_intent' || meta.lead.name || meta.lead.email || meta.lead.platform) {
|
||||
const dot = (filled, label) =>
|
||||
`<span style="display:inline-flex;align-items:center;gap:3px;margin-right:7px">` +
|
||||
`<span style="width:6px;height:6px;border-radius:50%;background:${filled ? '#7c5cff' : 'rgba(124,92,255,0.2)'}"></span>` +
|
||||
`<span style="font-size:9px;color:var(--text-muted)">${label}</span></span>`;
|
||||
leadDots = dot(!!meta.lead.name,'name') + dot(!!meta.lead.email,'email') + dot(!!meta.lead.platform,'platform');
|
||||
leadDots = `<span style="margin-left:4px">${leadDots}</span>`;
|
||||
}
|
||||
|
||||
badgeHtml = `<div style="margin-top:7px;display:flex;align-items:center;flex-wrap:wrap;gap:4px;">
|
||||
<span style="display:inline-flex;align-items:center;gap:5px;font-size:10px;
|
||||
font-family:'JetBrains Mono',monospace;padding:3px 9px;border-radius:6px;
|
||||
background:${col}18;border:1px solid ${col}40;color:${col};letter-spacing:0.04em;">
|
||||
<span style="width:5px;height:5px;border-radius:50%;background:${col}"></span>
|
||||
${meta.intent}${conf}
|
||||
</span>${leadDots}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="msg-sender">AutoStream AI</div>
|
||||
<div class="bubble">${html}${badgeHtml}</div>`;
|
||||
chatBox.appendChild(row);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function appendUserMessage(text) {
|
||||
if (!chatBox) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'msg-row user';
|
||||
row.innerHTML = `
|
||||
<div class="msg-sender">You</div>
|
||||
<div class="bubble">${escHtml(text)}</div>`;
|
||||
chatBox.appendChild(row);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// ── Typing ────────────────────────────────
|
||||
function showTyping(label = 'Thinking') {
|
||||
if (typingText) typingText.textContent = label;
|
||||
if (typingEl) typingEl.classList.add('visible');
|
||||
scrollToBottom();
|
||||
}
|
||||
function hideTyping() {
|
||||
if (typingEl) typingEl.classList.remove('visible');
|
||||
}
|
||||
|
||||
// ── Textarea auto-resize ──────────────────
|
||||
function autoResize() {
|
||||
if (!userInput) return;
|
||||
userInput.style.height = 'auto';
|
||||
userInput.style.height = Math.min(userInput.scrollHeight, 160) + 'px';
|
||||
}
|
||||
if (userInput) {
|
||||
userInput.addEventListener('input', autoResize);
|
||||
userInput.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
});
|
||||
userInput.addEventListener('focus', () => inputCard?.classList.add('focused'));
|
||||
userInput.addEventListener('blur', () => inputCard?.classList.remove('focused'));
|
||||
}
|
||||
|
||||
// ── Utils ─────────────────────────────────
|
||||
function scrollToBottom() { if (chatBox) chatBox.scrollTop = chatBox.scrollHeight; }
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
showWelcome();
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
pydantic>=2.0.0
|
||||
google-generativeai>=0.5.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# RAG / Embeddings (optional but recommended)
|
||||
sentence-transformers>=2.7.0
|
||||
faiss-cpu>=1.8.0
|
||||
numpy>=1.26.0
|
||||
Loading…
Reference in a new issue