Initial commit

This commit is contained in:
Kartvaya 2026-04-12 21:03:52 +05:30
commit a17102a7f8
18 changed files with 2148 additions and 0 deletions

250
README.md Normal file
View 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 56 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
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

300
backend/agent.py Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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 &nbsp;·&nbsp; 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
View 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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
showWelcome();

10
requirements.txt Normal file
View 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