Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6ecfc3a
Add web search capability with Tavily (per-bot control)
tpaulshippy Apr 2, 2026
587ec82
Address PR comments: add migration, error handling, token counting, a…
tpaulshippy Apr 3, 2026
dade515
Fix chat history, token tracking, max_iterations, and web_search retu…
tpaulshippy Apr 3, 2026
0f7e23e
Add GitHub Actions workflow for linting and testing
tpaulshippy Apr 3, 2026
fa99dcd
Fix lint errors and workflow
tpaulshippy Apr 3, 2026
08318b0
Fix build failures: update langchain imports, add test dependencies, …
tpaulshippy Apr 3, 2026
9a10fd5
Fix pytest failures: update langchain imports and fix mockito argumen…
tpaulshippy Apr 3, 2026
88ac8ad
Fix lint: remove unused langchain imports in test_chat.py
tpaulshippy Apr 4, 2026
969c3b7
Fix frontend lint and typecheck issues
tpaulshippy Apr 4, 2026
523e3db
Fix CI: add --watchAll=false to prevent jest from hanging in CI
tpaulshippy Apr 4, 2026
0e13458
Fix CSRF_TRUSTED_ORIGINS env var and add venv to gitignore
tpaulshippy Apr 4, 2026
8115cd3
Fix bug in get_chat_response and add hub prompt for ReAct agent
tpaulshippy Apr 4, 2026
174eecc
Add integration test script for web search feature
tpaulshippy Apr 4, 2026
655ff37
Update integration test with real news query to test web search
tpaulshippy Apr 4, 2026
48d0fdb
refactor: use LangChain ReAct agent pattern for tool calling with Bed…
tpaulshippy Apr 4, 2026
8fe8f5c
refactor: use modern langchain create_agent instead of deprecated cre…
tpaulshippy Apr 4, 2026
0965a6b
fix: resolve ruff linting errors and cleanup imports
tpaulshippy Apr 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/lint-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Lint and Test

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
frontend:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: front/package-lock.json

- name: Install dependencies
run: |
cd front
npm ci
npm install

- name: Run lint
run: |
cd front
npm run lint

- name: Run tests
run: |
cd front
npm test -- --passWithNoTests --coverage=false --watchAll=false

backend:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: back/requirements.txt

- name: Install dependencies
run: |
cd back
pip install -r requirements.txt
pip install ruff

- name: Run ruff
run: |
cd back
ruff check bots/ server/

- name: Run tests
run: |
cd back
pytest
1 change: 1 addition & 0 deletions back/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ __pycache__
db.sqlite3
test.http
*.pem
venv/
1 change: 0 additions & 1 deletion back/bots/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from django.contrib import admin
from django.apps import apps
from .models import Chat, Message, Profile, Bot, UserAccount, UsageLimitHit, AiModel, Device, RevenueCatWebhookEvent
from django.contrib.auth.models import User
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
Expand Down
2 changes: 1 addition & 1 deletion back/bots/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ class BotsConfig(AppConfig):
name = 'bots'

def ready(self):
import bots.signals
import bots.signals # noqa: F401
18 changes: 18 additions & 0 deletions back/bots/migrations/0033_add_enable_web_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2026-04-02

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('bots', '0032_usagelimithit_modified_at'),
]

operations = [
migrations.AddField(
model_name='bot',
name='enable_web_search',
field=models.BooleanField(default=False),
),
]
1 change: 1 addition & 0 deletions back/bots/models/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class Bot(models.Model):
response_length = models.IntegerField(default=200)
restrict_language = models.BooleanField(default=True)
restrict_adult_topics = models.BooleanField(default=True)
enable_web_search = models.BooleanField(default=False)

deleted_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
Expand Down
146 changes: 139 additions & 7 deletions back/bots/models/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,33 @@
from django.db import models
import uuid
from langchain_aws import ChatBedrock
from langchain.schema import HumanMessage, SystemMessage, AIMessage
import requests
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
from langchain_core.tools import tool
from langchain_core.callbacks.base import BaseCallbackHandler
from langchain.agents import create_agent
from tavily import TavilyClient
import logging
import base64
import boto3

from .profile import Profile
from .bot import Bot
from .ai_model import AiModel

logger = logging.getLogger(__name__)

class TokenTracker(BaseCallbackHandler):
def __init__(self):
self.input_tokens = 0
self.output_tokens = 0

def on_llm_end(self, response, *, run_id=None, parent_run_id=None, **kwargs):
usage_metadata = getattr(response, 'usage_metadata', None)
if usage_metadata:
self.input_tokens += usage_metadata.get('input_tokens', 0)
self.output_tokens += usage_metadata.get('output_tokens', 0)


S3_CLIENT = boto3.client('s3')
S3_BUCKET = settings.AWS_STORAGE_BUCKET_NAME

Expand Down Expand Up @@ -45,7 +63,7 @@ def __init__(self, *args, **kwargs):
self.ai = None

def __str__(self):
return self.title if self.user == None else self.user.email + ' - ' + self.title
return self.title if self.user is None else self.user.email + ' - ' + self.title

def use_default_model(self, ai=None):
try:
Expand All @@ -63,15 +81,129 @@ def get_response(self, ai=None):

message_list, contains_image = self.get_input()

# Check if any messages have image_filename and if the model supports images
if contains_image and self.bot and 'image' not in self.bot.ai_model.supported_input_modalities:
self.use_default_model(ai)

if self.user.user_account.over_limit():
return "You have exceeded your daily limit. Please try again tomorrow or upgrade your subscription."
response = self.ai.invoke(
message_list
)

# Use agent with tool calling if web search is enabled
if self.bot and self.bot.enable_web_search and settings.TAVILY_API_KEY:
logger.info(f"Web search enabled for bot {self.bot.name}")
tavily_client = TavilyClient(api_key=settings.TAVILY_API_KEY)
Comment on lines +91 to +93
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_response() now has a new web-search/agent execution branch, but the existing test suite for get_response (see back/bots/tests/test_chat.py) doesn’t cover this path. Add tests that validate: (1) branch selection when enable_web_search is true and API key present, (2) graceful fallback on Tavily/agent failure, and (3) token accounting/limit enforcement remains correct.

Copilot uses AI. Check for mistakes.

@tool
def web_search(query: str) -> str:
"""Search the web for current information. Use this when you need up-to-date information or facts that may not be in your training data."""
logger.info(f"🔍 WEB_SEARCH_TOOL_INVOKED: query='{query}'")
try:
results = tavily_client.search(query=query)
num_results = len(results.get('results', []))
logger.info(f"🔍 WEB_SEARCH_SUCCESS: returned {num_results} results")
# Format results as a readable string for the model
if results.get('results'):
formatted = "\n".join([
f"- {r.get('title', 'No title')}: {r.get('content', '')[:200]}"
for r in results['results'][:3]
])
logger.debug(f"🔍 WEB_SEARCH_FORMATTED_RESULTS:\n{formatted}")
return formatted
else:
logger.info("🔍 WEB_SEARCH_NO_RESULTS: empty result set")
return "No results found."
except Exception as e:
logger.error(f"🔍 WEB_SEARCH_ERROR: {str(e)}")
return f"Error during search: {str(e)}"


# Create chat model
chat_model = ChatBedrock(model_id=self.ai.model_id)
tools = [web_search]

# Create modern agent with tool calling support
# This is the recommended approach per LangChain docs
agent = create_agent(
model=chat_model,
tools=tools,
system_prompt=self.get_system_message(),
debug=settings.DEBUG
)

# Extract text input from message_list for agent
agent_input = self._extract_agent_input(message_list)

logger.info(f"Invoking agent with input: {agent_input[:100]}...")
logger.info("🤖 AGENT_INVOKE_START: web_search tool available")

# Invoke agent - the CompiledStateGraph handles tool loop internally
response = agent.invoke({"messages": [HumanMessage(content=agent_input)]})

logger.info("🤖 AGENT_INVOKE_COMPLETE: got response")

# Extract response text from the agent result
# The response is a dict with 'messages' key containing final messages
response_text = ""
usage_metadata = {"input_tokens": 0, "output_tokens": 0}

if isinstance(response, dict) and "messages" in response:
for msg in reversed(response["messages"]):
if isinstance(msg, AIMessage):
response_text = msg.content
# Extract token usage from the message metadata
if hasattr(msg, 'usage_metadata') and msg.usage_metadata:
usage_metadata = msg.usage_metadata
break
elif isinstance(response, dict) and "output" in response:
response_text = response["output"]
else:
response_text = str(response)

message_order = self.messages.count()

input_tokens = usage_metadata.get('input_tokens', 0)
output_tokens = usage_metadata.get('output_tokens', 0)

self.messages.create(
text=response_text,
role='assistant',
order=message_order,
input_tokens=input_tokens,
output_tokens=output_tokens
)
Comment on lines +160 to +172
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The web-search path never increments Chat.input_tokens / Chat.output_tokens before saving, so the chat-level totals used for daily limit calculation won’t reflect web-search usage. Update the chat’s token totals consistently with the non-web-search path.

Suggested change
response_text = response['output']
message_order = self.messages.count()
self.messages.create(
text=response_text,
role='assistant',
order=message_order,
input_tokens=0,
output_tokens=0
)
response_text = response['output']
usage_metadata = response.get('usage_metadata') if isinstance(response, dict) else None
input_tokens = usage_metadata.get('input_tokens', 0) if usage_metadata else 0
output_tokens = usage_metadata.get('output_tokens', 0) if usage_metadata else 0
message_order = self.messages.count()
self.messages.create(
text=response_text,
role='assistant',
order=message_order,
input_tokens=input_tokens,
output_tokens=output_tokens
)
self.input_tokens += input_tokens
self.output_tokens += output_tokens

Copilot uses AI. Check for mistakes.
self.input_tokens += input_tokens
self.output_tokens += output_tokens
self.save()
return response_text

# Standard response without web search
return self.get_response_standard(message_list, ai)

def _extract_agent_input(self, message_list):
"""Extract text input from message_list for the agent.

Handles both simple text and multimodal content.
Assumes message_list has system message at index 0 and user message at end.
"""
# Find the last non-system message (should be the user's query)
user_input = ""
for msg in reversed(message_list):
if isinstance(msg, HumanMessage):
if isinstance(msg.content, list):
# Multimodal content - extract text
for item in msg.content:
if isinstance(item, dict) and item.get('type') == 'text':
user_input = item.get('text', '')
break
else:
# Simple text content
user_input = msg.content
break

return user_input if user_input else "Please help me."

def get_response_standard(self, message_list, ai=None):
"""Handle response without web search."""
response = self.ai.invoke(message_list)

response_text = response.content
usage_metadata = response.usage_metadata
Expand Down
4 changes: 1 addition & 3 deletions back/bots/models/usage_limit_hit.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from datetime import datetime, time
from django.db import models
from .user_account import UserAccount
from django.utils import timezone
import pytz


class UsageLimitHit(models.Model):
user_account = models.ForeignKey(UserAccount,
Expand Down
1 change: 1 addition & 0 deletions back/bots/serializers/bot_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Meta:
'response_length',
'restrict_adult_topics',
'restrict_language',
'enable_web_search',
'created_at',
'modified_at',
'deleted_at',
Expand Down
2 changes: 1 addition & 1 deletion back/bots/serializers/chat_serializer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from rest_framework import serializers
from bots.models import Chat, Profile, Bot
from bots.models import Chat
from .message_serializer import MessageSerializer
from .profile_serializer import ProfileIdSerializer
from .bot_serializer import BotSerializer
Expand Down
Loading
Loading