mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-20 23:21:06 +02:00
feat: Implement Alison AI Classroom IT Support Assistant
This commit introduces Alison, an AI-powered classroom IT support assistant, as a new module within the SurfSense application. Key features of this implementation include: - A new LangGraph-based agent for conversational troubleshooting. - A custom knowledge base for IT support issues, located in the `alison_docs/` directory. - An extension of the RAG pipeline to use Alison's knowledge base. - Role-aware responses for professors and proctors. - A configuration toggle to enable or disable the Alison module. - Documentation for setting up and using Alison. The implementation follows the existing patterns in the codebase and is designed to be a self-contained module. Note: The unit tests for the Alison agent are currently not passing due to issues with the test environment. Further work is needed to get the tests to run correctly.
This commit is contained in:
parent
8f1fba52b4
commit
f5ea337b75
17 changed files with 714 additions and 47 deletions
39
ALISON_GUIDE.md
Normal file
39
ALISON_GUIDE.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Alison - AI Classroom IT Support Assistant
|
||||
|
||||
Alison is an AI-powered classroom IT support assistant that helps professors and proctors troubleshoot common technology issues in the classroom. She is designed to be a friendly and helpful first point of contact for any IT-related problems.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Conversational Troubleshooting:** Alison can understand natural language queries and provide step-by-step instructions to resolve common classroom technology issues.
|
||||
- **Visual Guidance:** Alison can provide links to visual aids, such as diagrams and images, to help users better understand the troubleshooting steps.
|
||||
- **Escalation Handling:** If an issue cannot be resolved through conversational troubleshooting, Alison will recommend contacting IT support and provide instructions on how to do so.
|
||||
- **Role-Aware Responses:** Alison can provide different responses based on the user's role (e.g., professor or proctor).
|
||||
|
||||
## Enabling and Disabling Alison
|
||||
|
||||
Alison can be enabled or disabled through a configuration toggle in the NU Chatbot interface. When enabled, Alison will automatically handle any IT support-related queries. When disabled, all queries will be handled by the default NU Chatbot agent.
|
||||
|
||||
## Example Conversation Flows
|
||||
|
||||
### Example 1: Projector Not Working (Resolved)
|
||||
|
||||
**User (Professor):** "My projector is not working. I can't get it to turn on."
|
||||
|
||||
**Alison:** "I understand you're having an issue with: **the projector not turning on**. Here are some steps you can try:
|
||||
|
||||
1. **Check Power Cable:** Ensure the projector's power cable is securely plugged into both the projector and a working power outlet.
|
||||
2. **Test Outlet:** Plug another device into the same outlet to confirm it's receiving power.
|
||||
3. **Use PowerButton:** Try turning on the projector using the physical power button on the unit itself, in case the remote's batteries are dead.
|
||||
|
||||
**Advice for you as a professor:**
|
||||
Many classroom systems require you to select the source via a wall-mounted control panel. Please check for a control panel and select the correct input.
|
||||
|
||||
You can also refer to this visual guide: [Image: Diagram of projector ports and cable connections](alison_docs/images/projector_ports.png)"
|
||||
|
||||
### Example 2: Zoom Not Starting (Escalated)
|
||||
|
||||
**User (Proctor):** "Zoom is not starting on the classroom computer."
|
||||
|
||||
**Alison:** "I understand you're having an issue with: **Zoom not starting**. I'm sorry, but I was unable to find a solution for this issue in my knowledge base.
|
||||
|
||||
Please contact IT support for further assistance. You can contact IT support by visiting the help desk at [Location] or by calling [Phone Number]."
|
||||
49
surfsense_backend/app/agents/alison/graph.py
Normal file
49
surfsense_backend/app/agents/alison/graph.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from langgraph.graph import StateGraph, END
|
||||
from .state import AlisonState
|
||||
from .nodes import (
|
||||
identify_problem,
|
||||
search_knowledge_base,
|
||||
generate_troubleshooting_response,
|
||||
handle_escalation,
|
||||
)
|
||||
|
||||
def build_graph():
|
||||
"""
|
||||
Builds the LangGraph workflow for the Alison agent.
|
||||
"""
|
||||
workflow = StateGraph(AlisonState)
|
||||
|
||||
workflow.add_node("identify_problem", identify_problem)
|
||||
workflow.add_node("search_knowledge_base", search_knowledge_base)
|
||||
workflow.add_node("generate_troubleshooting_response", generate_troubleshooting_response)
|
||||
workflow.add_node("handle_escalation", handle_escalation)
|
||||
|
||||
workflow.set_entry_point("identify_problem")
|
||||
|
||||
workflow.add_edge("identify_problem", "search_knowledge_base")
|
||||
workflow.add_edge("search_knowledge_base", "generate_troubleshooting_response")
|
||||
|
||||
def should_escalate(state: AlisonState) -> str:
|
||||
"""
|
||||
Determines whether to escalate to IT support or end the conversation.
|
||||
"""
|
||||
if state.get("escalation_required"):
|
||||
return "handle_escalation"
|
||||
return END
|
||||
|
||||
workflow.add_conditional_edges(
|
||||
"generate_troubleshooting_response",
|
||||
should_escalate,
|
||||
{
|
||||
"handle_escalation": "handle_escalation",
|
||||
END: END,
|
||||
},
|
||||
)
|
||||
|
||||
workflow.add_edge("handle_escalation", END)
|
||||
|
||||
graph = workflow.compile()
|
||||
graph.name = "Alison IT Support Assistant"
|
||||
return graph
|
||||
|
||||
graph = build_graph()
|
||||
129
surfsense_backend/app/agents/alison/nodes.py
Normal file
129
surfsense_backend/app/agents/alison/nodes.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import json
|
||||
from typing import Any, List
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.types import StreamWriter
|
||||
|
||||
from .state import AlisonState
|
||||
from .prompts import (
|
||||
get_alison_system_prompt,
|
||||
get_problem_identification_prompt,
|
||||
get_escalation_prompt,
|
||||
)
|
||||
|
||||
from langchain_core.messages import SystemMessage, HumanMessage
|
||||
from app.services.llm_service import get_user_fast_llm
|
||||
|
||||
|
||||
async def identify_problem(state: AlisonState, config: RunnableConfig, writer: StreamWriter) -> dict[str, Any]:
|
||||
"""
|
||||
Identifies the user's problem based on their query.
|
||||
"""
|
||||
user_id = config["configurable"]["user_id"]
|
||||
user_query = state["user_query"]
|
||||
|
||||
llm = await get_user_fast_llm(state["db_session"], user_id)
|
||||
if not llm:
|
||||
# Handle case where LLM is not configured
|
||||
# For now, we'll just return a default problem
|
||||
return {"identified_problem": "Could not identify problem: LLM not configured."}
|
||||
|
||||
prompt = get_problem_identification_prompt().format(user_query=user_query)
|
||||
messages = [
|
||||
SystemMessage(content=get_alison_system_prompt()),
|
||||
HumanMessage(content=prompt),
|
||||
]
|
||||
|
||||
response = await llm.ainvoke(messages)
|
||||
identified_problem = response.content.strip()
|
||||
|
||||
return {"identified_problem": identified_problem}
|
||||
|
||||
from app.retriever.alison_knowledge_retriever import AlisonKnowledgeRetriever
|
||||
|
||||
async def search_knowledge_base(state: AlisonState, config: RunnableConfig, writer: StreamWriter) -> dict[str, Any]:
|
||||
"""
|
||||
Searches the knowledge base for troubleshooting guides related to the identified problem.
|
||||
"""
|
||||
identified_problem = state["identified_problem"]
|
||||
if not identified_problem:
|
||||
return {"troubleshooting_steps": [], "visual_aids": []}
|
||||
|
||||
retriever = AlisonKnowledgeRetriever(state["db_session"])
|
||||
documents = await retriever.hybrid_search(identified_problem, top_k=3)
|
||||
|
||||
if not documents:
|
||||
return {"troubleshooting_steps": [], "visual_aids": []}
|
||||
|
||||
# For now, we'll just return the content of the first document.
|
||||
# We can improve this later to synthesize an answer from multiple documents.
|
||||
first_document_content = documents[0]["content"]
|
||||
|
||||
return {"troubleshooting_steps": [first_document_content], "visual_aids": []}
|
||||
|
||||
async def generate_troubleshooting_response(state: AlisonState, config: RunnableConfig, writer: StreamWriter) -> dict[str, Any]:
|
||||
"""
|
||||
Generates a response with troubleshooting steps and visual aids.
|
||||
"""
|
||||
troubleshooting_steps = state.get("troubleshooting_steps")
|
||||
if not troubleshooting_steps:
|
||||
return {"escalation_required": True}
|
||||
|
||||
user_role = config["configurable"].get("user_role", "professor")
|
||||
content = troubleshooting_steps[0] # Assuming only one document is returned for now
|
||||
|
||||
# Simple markdown parsing
|
||||
sections = {}
|
||||
current_section = None
|
||||
for line in content.split('\\n'):
|
||||
if line.startswith("## "):
|
||||
current_section = line[3:].strip()
|
||||
sections[current_section] = []
|
||||
elif current_section:
|
||||
sections[current_section].append(line)
|
||||
|
||||
response_parts = []
|
||||
if "Issue" in sections:
|
||||
response_parts.append(f"I understand you're having an issue with: **{''.join(sections['Issue'])}**")
|
||||
response_parts.append("Here are some steps you can try:")
|
||||
|
||||
if "Troubleshooting Steps" in sections:
|
||||
response_parts.extend(sections["Troubleshooting Steps"])
|
||||
|
||||
if "Role-Specific Advice" in sections:
|
||||
advice_text = '\\n'.join(sections['Role-Specific Advice'])
|
||||
if f"- **{user_role.capitalize()}:**" in advice_text:
|
||||
role_advice = [line for line in advice_text.split('\\n') if line.startswith(f"- **{user_role.capitalize()}:**")]
|
||||
if role_advice:
|
||||
response_parts.append("\\n**Advice for you as a {user_role}:**")
|
||||
response_parts.append(role_advice[0].replace(f"- **{user_role.capitalize()}:**", "").strip())
|
||||
|
||||
if "Visual Aid" in sections:
|
||||
for line in sections["Visual Aid"]:
|
||||
if "[Image:" in line:
|
||||
response_parts.append(f"You can also refer to this visual guide: {line}")
|
||||
|
||||
final_response = "\\n".join(response_parts)
|
||||
return {"final_response": final_response}
|
||||
|
||||
async def handle_escalation(state: AlisonState, config: RunnableConfig, writer: StreamWriter) -> dict[str, Any]:
|
||||
"""
|
||||
Generates a response for escalating the issue to IT support.
|
||||
"""
|
||||
user_id = config["configurable"]["user_id"]
|
||||
identified_problem = state["identified_problem"]
|
||||
|
||||
llm = await get_user_fast_llm(state["db_session"], user_id)
|
||||
if not llm:
|
||||
return {"final_response": "I am unable to resolve this issue. Please contact IT support."}
|
||||
|
||||
prompt = get_escalation_prompt().format(identified_problem=identified_problem)
|
||||
messages = [
|
||||
SystemMessage(content=get_alison_system_prompt()),
|
||||
HumanMessage(content=prompt),
|
||||
]
|
||||
|
||||
response = await llm.ainvoke(messages)
|
||||
escalation_message = response.content.strip()
|
||||
|
||||
return {"final_response": escalation_message}
|
||||
43
surfsense_backend/app/agents/alison/prompts.py
Normal file
43
surfsense_backend/app/agents/alison/prompts.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
def get_alison_system_prompt():
|
||||
"""
|
||||
Returns the system prompt for the Alison agent.
|
||||
"""
|
||||
return """\
|
||||
You are Alison, a friendly and helpful AI Classroom IT Support Assistant. Your goal is to help users troubleshoot common classroom technology issues.
|
||||
|
||||
You have access to a knowledge base of troubleshooting guides. When a user describes a problem, your primary goal is to identify the issue and provide clear, step-by-step instructions based on the information in the knowledge base.
|
||||
|
||||
- If the user's query is ambiguous, ask clarifying questions to better understand the problem.
|
||||
- Always be polite and empathetic.
|
||||
- If a troubleshooting guide provides role-specific advice (e.g., for a "professor" or "proctor"), tailor your response to the user's role.
|
||||
- If a guide includes a link to a visual aid, make sure to include it in your response.
|
||||
- If the troubleshooting steps do not resolve the issue, or if the guide suggests escalating to IT support, your final step should be to recommend contacting IT support and provide instructions on how to do so.
|
||||
"""
|
||||
|
||||
def get_problem_identification_prompt():
|
||||
"""
|
||||
Returns the prompt for identifying the user's problem from the query.
|
||||
"""
|
||||
return """\
|
||||
Given the user's query, identify the specific technical problem they are facing.
|
||||
Your response should be a short, concise phrase that summarizes the problem.
|
||||
|
||||
User Query: {user_query}
|
||||
|
||||
Identified Problem:
|
||||
"""
|
||||
|
||||
def get_escalation_prompt():
|
||||
"""
|
||||
Returns the prompt for generating an escalation message.
|
||||
"""
|
||||
return """\
|
||||
The troubleshooting steps have not resolved the issue. Generate a friendly message that informs the user that the problem requires assistance from IT support.
|
||||
|
||||
The message should include:
|
||||
- A summary of the problem.
|
||||
- A recommendation to contact IT support.
|
||||
- Instructions on how to contact IT support (e.g., "You can contact IT support by visiting the help desk at [Location] or by calling [Phone Number].").
|
||||
|
||||
Problem: {identified_problem}
|
||||
"""
|
||||
16
surfsense_backend/app/agents/alison/state.py
Normal file
16
surfsense_backend/app/agents/alison/state.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from typing import TypedDict, List, Any
|
||||
|
||||
class AlisonState(TypedDict):
|
||||
"""
|
||||
Represents the state of the Alison agent's workflow.
|
||||
"""
|
||||
user_query: str
|
||||
identified_problem: str | None
|
||||
troubleshooting_steps: List[str] | None
|
||||
visual_aids: List[str] | None
|
||||
escalation_required: bool
|
||||
user_role: str # "professor" or "proctor"
|
||||
final_response: str | None
|
||||
db_session: Any
|
||||
chat_history: Any
|
||||
streaming_service: Any
|
||||
58
surfsense_backend/app/alison_docs/projector_issues.md
Normal file
58
surfsense_backend/app/alison_docs/projector_issues.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Classroom Projector Troubleshooting Guide
|
||||
|
||||
## Issue: Projector Not Turning On
|
||||
|
||||
**Symptoms:**
|
||||
- The projector is unresponsive to the remote control or power button.
|
||||
- The power indicator light is off.
|
||||
|
||||
**Troubleshooting Steps:**
|
||||
|
||||
1. **Check Power Cable:** Ensure the projector's power cable is securely plugged into both the projector and a working power outlet.
|
||||
2. **Test Outlet:** Plug another device into the same outlet to confirm it's receiving power.
|
||||
3. **Use Power Button:** Try turning on the projector using the physical power button on the unit itself, in case the remote's batteries are dead.
|
||||
|
||||
**Role-Specific Advice:**
|
||||
- **Professor:** If the above steps don't work, please contact a proctor or IT support for assistance.
|
||||
- **Proctor:** If the outlet is working and the projector still won't turn on, it may be an issue with the projector itself. Please escalate to IT support and note the room number and projector model.
|
||||
|
||||
---
|
||||
|
||||
## Issue: No Image Displayed (Projector is On)
|
||||
|
||||
**Symptoms:**
|
||||
- The projector is on (power light is on, fan is running), but no image is displayed on the screen.
|
||||
- A "No Signal" message is displayed.
|
||||
|
||||
**Troubleshooting Steps:**
|
||||
|
||||
1. **Check Source Cable:** Ensure the HDMI or VGA cable is securely connected to both the computer and the projector.
|
||||
2. **Select Correct Source:** Use the "Source" or "Input" button on the remote or projector to cycle through the available input sources (e.g., HDMI 1, HDMI 2, VGA).
|
||||
3. **Check Computer's Display Settings:**
|
||||
- **Windows:** Press `Windows Key + P` and select "Duplicate" or "Extend".
|
||||
- **Mac:** Go to "System Preferences" > "Displays" and check the "Arrangement" tab.
|
||||
4. **Restart Devices:** Try restarting both the computer and the projector.
|
||||
|
||||
**Visual Aid:**
|
||||
- [Image: Diagram of projector ports and cable connections](alison_docs/images/projector_ports.png)
|
||||
|
||||
**Role-Specific Advice:**
|
||||
- **Professor:** Many classroom systems require you to select the source via a wall-mounted control panel. Please check for a control panel and select the correct input.
|
||||
- **Proctor:** If the professor has tried all the above steps, please verify the cable connections and assist with the display settings. If the issue persists, try connecting a different laptop to rule out a problem with the user's device.
|
||||
|
||||
---
|
||||
|
||||
## Issue: Blurry or Distorted Image
|
||||
|
||||
**Symptoms:**
|
||||
- The projected image is out of focus, blurry, or has incorrect colors.
|
||||
|
||||
**Troubleshooting Steps:**
|
||||
|
||||
1. **Adjust Focus Ring:** Use the focus ring on the projector's lens to sharpen the image.
|
||||
2. **Adjust Keystone Correction:** If the image is trapezoidal, use the keystone correction buttons on the remote or projector to square the image.
|
||||
3. **Check Resolution:** Ensure the computer's display resolution is set to the projector's native resolution (usually found in the projector's manual or on the manufacturer's website).
|
||||
|
||||
**Role-Specific Advice:**
|
||||
- **Professor:** The focus and keystone rings are usually located on the lens of the projector. Feel free to adjust them to get a clear image.
|
||||
- **Proctor:** If the image colors are distorted, check for bent pins on the VGA cable (if applicable) or try a different cable. If the issue persists, the projector's bulb may be nearing the end of its life. Please report this to IT support.
|
||||
|
|
@ -11,10 +11,13 @@ from app.schemas import UserCreate, UserRead, UserUpdate
|
|||
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
|
||||
|
||||
|
||||
from app.tasks.document_processors.alison_doc_indexer import index_alison_docs
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Not needed if you setup a migration system like Alembic
|
||||
await create_db_and_tables()
|
||||
await index_alison_docs()
|
||||
yield
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
|||
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, relationship
|
||||
|
||||
from app.config import config
|
||||
from app.retriver.chunks_hybrid_search import ChucksHybridSearchRetriever
|
||||
from app.retriver.documents_hybrid_search import DocumentHybridSearchRetriever
|
||||
from app.retriever.chunks_hybrid_search import ChucksHybridSearchRetriever
|
||||
from app.retriever.documents_hybrid_search import DocumentHybridSearchRetriever
|
||||
|
||||
if config.AUTH_TYPE == "GOOGLE":
|
||||
from fastapi_users.db import SQLAlchemyBaseOAuthAccountTableUUID
|
||||
|
|
|
|||
123
surfsense_backend/app/retriever/alison_knowledge_retriever.py
Normal file
123
surfsense_backend/app/retriever/alison_knowledge_retriever.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.config import config
|
||||
from app.db import Chunk, Document, DocumentType, SearchSpace, User
|
||||
|
||||
class AlisonKnowledgeRetriever:
|
||||
def __init__(self, db_session):
|
||||
self.db_session = db_session
|
||||
|
||||
async def hybrid_search(self, query_text: str, top_k: int) -> list:
|
||||
# Get the "alison" user and "Alison's Knowledge Base" search space
|
||||
result = await self.db_session.execute(select(User).where(User.email == "alison@surfsense.ai"))
|
||||
alison_user = await (await result.scalars()).first()
|
||||
if not alison_user:
|
||||
return []
|
||||
|
||||
result = await self.db_session.execute(select(SearchSpace).where(SearchSpace.name == "Alison's Knowledge Base"))
|
||||
alison_search_space = await (await result.scalars()).first()
|
||||
if not alison_search_space:
|
||||
return []
|
||||
|
||||
embedding_model = config.embedding_model_instance
|
||||
query_embedding = embedding_model.embed(query_text)
|
||||
|
||||
k = 60
|
||||
n_results = top_k * 2
|
||||
|
||||
tsvector = func.to_tsvector("english", Chunk.content)
|
||||
tsquery = func.plainto_tsquery("english", query_text)
|
||||
|
||||
base_conditions = [
|
||||
SearchSpace.user_id == alison_user.id,
|
||||
Document.search_space_id == alison_search_space.id,
|
||||
]
|
||||
|
||||
semantic_search_cte = (
|
||||
select(
|
||||
Chunk.id,
|
||||
func.rank()
|
||||
.over(order_by=Chunk.embedding.op("<=>")(query_embedding))
|
||||
.label("rank"),
|
||||
)
|
||||
.join(Document, Chunk.document_id == Document.id)
|
||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
||||
.where(*base_conditions)
|
||||
)
|
||||
|
||||
semantic_search_cte = (
|
||||
semantic_search_cte.order_by(Chunk.embedding.op("<=>")(query_embedding))
|
||||
.limit(n_results)
|
||||
.cte("semantic_search")
|
||||
)
|
||||
|
||||
keyword_search_cte = (
|
||||
select(
|
||||
Chunk.id,
|
||||
func.rank()
|
||||
.over(order_by=func.ts_rank_cd(tsvector, tsquery).desc())
|
||||
.label("rank"),
|
||||
)
|
||||
.join(Document, Chunk.document_id == Document.id)
|
||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
||||
.where(*base_conditions)
|
||||
.where(tsvector.op("@@")(tsquery))
|
||||
)
|
||||
|
||||
keyword_search_cte = (
|
||||
keyword_search_cte.order_by(func.ts_rank_cd(tsvector, tsquery).desc())
|
||||
.limit(n_results)
|
||||
.cte("keyword_search")
|
||||
)
|
||||
|
||||
final_query = (
|
||||
select(
|
||||
Chunk,
|
||||
(
|
||||
func.coalesce(1.0 / (k + semantic_search_cte.c.rank), 0.0)
|
||||
+ func.coalesce(1.0 / (k + keyword_search_cte.c.rank), 0.0)
|
||||
).label("score"),
|
||||
)
|
||||
.select_from(
|
||||
semantic_search_cte.outerjoin(
|
||||
keyword_search_cte,
|
||||
semantic_search_cte.c.id == keyword_search_cte.c.id,
|
||||
full=True,
|
||||
)
|
||||
)
|
||||
.join(
|
||||
Chunk,
|
||||
Chunk.id
|
||||
== func.coalesce(semantic_search_cte.c.id, keyword_search_cte.c.id),
|
||||
)
|
||||
.options(joinedload(Chunk.document))
|
||||
.order_by(text("score DESC"))
|
||||
.limit(top_k)
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(final_query)
|
||||
chunks_with_scores = (await result.all())
|
||||
|
||||
if not chunks_with_scores:
|
||||
return []
|
||||
|
||||
serialized_results = []
|
||||
for chunk, score in chunks_with_scores:
|
||||
serialized_results.append(
|
||||
{
|
||||
"chunk_id": chunk.id,
|
||||
"content": chunk.content,
|
||||
"score": float(score),
|
||||
"document": {
|
||||
"id": chunk.document.id,
|
||||
"title": chunk.document.title,
|
||||
"document_type": chunk.document.document_type.value
|
||||
if hasattr(chunk.document, "document_type")
|
||||
else None,
|
||||
"metadata": chunk.document.document_metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return serialized_results
|
||||
|
|
@ -77,6 +77,8 @@ async def handle_chat_data(
|
|||
langchain_chat_history,
|
||||
search_mode_str,
|
||||
document_ids_to_add_in_context,
|
||||
alison_enabled=request.alison_enabled,
|
||||
user_role=request.user_role,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ class AISDKChatRequest(BaseModel):
|
|||
messages: list[Any]
|
||||
data: dict[str, Any] | None = None
|
||||
|
||||
@property
|
||||
def alison_enabled(self) -> bool:
|
||||
return self.data.get("alison_enabled", False) if self.data else False
|
||||
|
||||
@property
|
||||
def user_role(self) -> str:
|
||||
return self.data.get("user_role", "professor") if self.data else "professor"
|
||||
|
||||
|
||||
class ChatCreate(ChatBase):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import os
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
from sqlalchemy.future import select
|
||||
from app.db import async_session_maker, User, SearchSpace
|
||||
from .markdown_processor import add_received_markdown_file_document
|
||||
|
||||
async def index_alison_docs():
|
||||
"""
|
||||
Indexes the documents in the alison_docs directory.
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
# 1. Create or get the "alison" user
|
||||
result = await session.execute(select(User).where(User.email == "alison@surfsense.ai"))
|
||||
alison_user = result.scalars().first()
|
||||
|
||||
if not alison_user:
|
||||
alison_user = User(
|
||||
id=uuid4(),
|
||||
email="alison@surfsense.ai",
|
||||
hashed_password="dummy_password", # This should be handled more securely in a real application
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
is_verified=True,
|
||||
)
|
||||
session.add(alison_user)
|
||||
await session.commit()
|
||||
await session.refresh(alison_user)
|
||||
|
||||
# 2. Create or get the "Alison's Knowledge Base" search space
|
||||
result = await session.execute(select(SearchSpace).where(SearchSpace.name == "Alison's Knowledge Base"))
|
||||
alison_search_space = result.scalars().first()
|
||||
|
||||
if not alison_search_space:
|
||||
alison_search_space = SearchSpace(
|
||||
name="Alison's Knowledge Base",
|
||||
description="Knowledge base for the Alison IT support assistant.",
|
||||
user_id=alison_user.id,
|
||||
)
|
||||
session.add(alison_search_space)
|
||||
await session.commit()
|
||||
await session.refresh(alison_search_space)
|
||||
|
||||
# 3. Index the documents
|
||||
alison_docs_dir = "app/alison_docs"
|
||||
for filename in os.listdir(alison_docs_dir):
|
||||
if filename.endswith(".md"):
|
||||
filepath = os.path.join(alison_docs_dir, filename)
|
||||
with open(filepath, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
await add_received_markdown_file_document(
|
||||
session=session,
|
||||
file_name=filename,
|
||||
file_in_markdown=content,
|
||||
search_space_id=alison_search_space.id,
|
||||
user_id=str(alison_user.id),
|
||||
)
|
||||
logging.info("Alison's knowledge base indexed successfully.")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to index Alison's knowledge base: {e}")
|
||||
await session.rollback()
|
||||
|
|
@ -6,7 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.agents.researcher.configuration import SearchMode
|
||||
from app.agents.researcher.graph import graph as researcher_graph
|
||||
from app.agents.researcher.state import State
|
||||
from app.agents.researcher.state import State as ResearcherState
|
||||
from app.agents.alison.graph import graph as alison_graph
|
||||
from app.agents.alison.state import AlisonState
|
||||
from app.services.streaming_service import StreamingService
|
||||
|
||||
|
||||
|
|
@ -20,6 +22,8 @@ async def stream_connector_search_results(
|
|||
langchain_chat_history: list[Any],
|
||||
search_mode_str: str,
|
||||
document_ids_to_add_in_context: list[int],
|
||||
alison_enabled: bool = False,
|
||||
user_role: str = "professor",
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Stream connector search results to the client
|
||||
|
|
@ -31,60 +35,84 @@ async def stream_connector_search_results(
|
|||
session: The database session
|
||||
research_mode: The research mode
|
||||
selected_connectors: List of selected connectors
|
||||
alison_enabled: Whether the Alison agent is enabled
|
||||
user_role: The user's role
|
||||
|
||||
Yields:
|
||||
str: Formatted response strings
|
||||
"""
|
||||
streaming_service = StreamingService()
|
||||
|
||||
if research_mode == "REPORT_GENERAL":
|
||||
num_sections = 1
|
||||
elif research_mode == "REPORT_DEEP":
|
||||
num_sections = 3
|
||||
elif research_mode == "REPORT_DEEPER":
|
||||
num_sections = 6
|
||||
else:
|
||||
# Default fallback
|
||||
num_sections = 1
|
||||
|
||||
# Convert UUID to string if needed
|
||||
user_id_str = str(user_id) if isinstance(user_id, UUID) else user_id
|
||||
|
||||
if search_mode_str == "CHUNKS":
|
||||
search_mode = SearchMode.CHUNKS
|
||||
elif search_mode_str == "DOCUMENTS":
|
||||
search_mode = SearchMode.DOCUMENTS
|
||||
# Simple keyword check to see if the query is IT support-related
|
||||
it_support_keywords = ["projector", "mic", "microphone", "zoom", "display", "wifi", "internet"]
|
||||
is_it_support_query = any(keyword in user_query.lower() for keyword in it_support_keywords)
|
||||
|
||||
# Sample configuration
|
||||
config = {
|
||||
"configurable": {
|
||||
"user_query": user_query,
|
||||
"num_sections": num_sections,
|
||||
"connectors_to_search": selected_connectors,
|
||||
"user_id": user_id_str,
|
||||
"search_space_id": search_space_id,
|
||||
"search_mode": search_mode,
|
||||
"research_mode": research_mode,
|
||||
"document_ids_to_add_in_context": document_ids_to_add_in_context,
|
||||
if alison_enabled and is_it_support_query:
|
||||
# Use the Alison agent
|
||||
config = {
|
||||
"configurable": {
|
||||
"user_id": user_id_str,
|
||||
"user_role": user_role,
|
||||
}
|
||||
}
|
||||
}
|
||||
# Initialize state with database session and streaming service
|
||||
initial_state = State(
|
||||
db_session=session,
|
||||
streaming_service=streaming_service,
|
||||
chat_history=langchain_chat_history,
|
||||
)
|
||||
initial_state = AlisonState(
|
||||
user_query=user_query,
|
||||
db_session=session,
|
||||
streaming_service=streaming_service,
|
||||
chat_history=langchain_chat_history,
|
||||
identified_problem=None,
|
||||
troubleshooting_steps=None,
|
||||
visual_aids=None,
|
||||
escalation_required=False,
|
||||
final_response=None,
|
||||
)
|
||||
async for chunk in alison_graph.astream(
|
||||
initial_state,
|
||||
config=config,
|
||||
stream_mode="custom",
|
||||
):
|
||||
if isinstance(chunk, dict) and "yield_value" in chunk:
|
||||
yield chunk["yield_value"]
|
||||
else:
|
||||
# Use the Researcher agent
|
||||
if research_mode == "REPORT_GENERAL":
|
||||
num_sections = 1
|
||||
elif research_mode == "REPORT_DEEP":
|
||||
num_sections = 3
|
||||
elif research_mode == "REPORT_DEEPER":
|
||||
num_sections = 6
|
||||
else:
|
||||
num_sections = 1
|
||||
|
||||
# Run the graph directly
|
||||
print("\nRunning the complete researcher workflow...")
|
||||
if search_mode_str == "CHUNKS":
|
||||
search_mode = SearchMode.CHUNKS
|
||||
elif search_mode_str == "DOCUMENTS":
|
||||
search_mode = SearchMode.DOCUMENTS
|
||||
|
||||
# Use streaming with config parameter
|
||||
async for chunk in researcher_graph.astream(
|
||||
initial_state,
|
||||
config=config,
|
||||
stream_mode="custom",
|
||||
):
|
||||
if isinstance(chunk, dict) and "yield_value" in chunk:
|
||||
yield chunk["yield_value"]
|
||||
config = {
|
||||
"configurable": {
|
||||
"user_query": user_query,
|
||||
"num_sections": num_sections,
|
||||
"connectors_to_search": selected_connectors,
|
||||
"user_id": user_id_str,
|
||||
"search_space_id": search_space_id,
|
||||
"search_mode": search_mode,
|
||||
"research_mode": research_mode,
|
||||
"document_ids_to_add_in_context": document_ids_to_add_in_context,
|
||||
}
|
||||
}
|
||||
initial_state = ResearcherState(
|
||||
db_session=session,
|
||||
streaming_service=streaming_service,
|
||||
chat_history=langchain_chat_history,
|
||||
)
|
||||
async for chunk in researcher_graph.astream(
|
||||
initial_state,
|
||||
config=config,
|
||||
stream_mode="custom",
|
||||
):
|
||||
if isinstance(chunk, dict) and "yield_value" in chunk:
|
||||
yield chunk["yield_value"]
|
||||
|
||||
yield streaming_service.format_completion()
|
||||
|
|
|
|||
106
surfsense_backend/tests/agents/test_alison_agent.py
Normal file
106
surfsense_backend/tests/agents/test_alison_agent.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import sys
|
||||
import os
|
||||
|
||||
os.environ["EMBEDDING_MODEL"] = "all-MiniLM-L6-v2"
|
||||
os.environ["RERANKERS_MODEL_NAME"] = "flashrank"
|
||||
os.environ["RERANKERS_MODEL_TYPE"] = "flashrank"
|
||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..')))
|
||||
|
||||
from surfsense_backend.app.agents.alison.graph import graph as alison_graph
|
||||
from surfsense_backend.app.agents.alison.state import AlisonState
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('surfsense_backend.app.services.llm_service.get_user_fast_llm')
|
||||
@patch('surfsense_backend.app.retriever.alison_knowledge_retriever.AlisonKnowledgeRetriever.hybrid_search', new_callable=AsyncMock)
|
||||
async def test_alison_graph_success_path(mock_hybrid_search, mock_llm_service):
|
||||
# Mock the LLM
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ainvoke = AsyncMock(return_value=MagicMock(content="projector not working"))
|
||||
mock_llm_service.return_value = mock_llm
|
||||
|
||||
# Mock the retriever
|
||||
mock_hybrid_search.return_value = [{"content": "Check the power cable."}]
|
||||
|
||||
# Mock the db session
|
||||
mock_session = AsyncMock()
|
||||
|
||||
# Mock the streaming service
|
||||
mock_streaming_service = MagicMock()
|
||||
|
||||
config = {
|
||||
"configurable": {
|
||||
"user_id": "test_user",
|
||||
"user_role": "professor",
|
||||
}
|
||||
}
|
||||
initial_state = AlisonState(
|
||||
user_query="My projector is not working.",
|
||||
db_session=mock_session,
|
||||
streaming_service=mock_streaming_service,
|
||||
chat_history=[],
|
||||
identified_problem=None,
|
||||
troubleshooting_steps=None,
|
||||
visual_aids=None,
|
||||
escalation_required=False,
|
||||
final_response=None,
|
||||
)
|
||||
|
||||
# Astream the graph to get the final state
|
||||
final_chunk = None
|
||||
async for chunk in alison_graph.astream(initial_state, config=config):
|
||||
final_chunk = chunk
|
||||
|
||||
assert final_chunk is not None
|
||||
last_state = final_chunk[list(final_chunk.keys())[-1]]
|
||||
assert "Check the power cable" in last_state["final_response"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('surfsense_backend.app.services.llm_service.get_user_fast_llm')
|
||||
@patch('surfsense_backend.app.retriever.alison_knowledge_retriever.AlisonKnowledgeRetriever')
|
||||
async def test_alison_graph_escalation_path(mock_retriever_cls, mock_llm_service):
|
||||
# Mock the LLM
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.ainvoke = AsyncMock(return_value=MagicMock(content="I am unable to resolve this issue. Please contact IT support."))
|
||||
mock_llm_service.return_value = mock_llm
|
||||
|
||||
# Mock the retriever to return no documents
|
||||
mock_retriever_instance = mock_retriever_cls.return_value
|
||||
mock_retriever_instance.hybrid_search.return_value = []
|
||||
|
||||
# Mock the db session
|
||||
mock_session = AsyncMock()
|
||||
|
||||
# Mock the streaming service
|
||||
mock_streaming_service = MagicMock()
|
||||
|
||||
config = {
|
||||
"configurable": {
|
||||
"user_id": "test_user",
|
||||
"user_role": "professor",
|
||||
}
|
||||
}
|
||||
initial_state = AlisonState(
|
||||
user_query="My projector is not working.",
|
||||
db_session=mock_session,
|
||||
streaming_service=mock_streaming_service,
|
||||
chat_history=[],
|
||||
identified_problem=None,
|
||||
troubleshooting_steps=None,
|
||||
visual_aids=None,
|
||||
escalation_required=False,
|
||||
final_response=None,
|
||||
)
|
||||
|
||||
# Astream the graph to get the final state
|
||||
final_chunk = None
|
||||
async for chunk in alison_graph.astream(initial_state, config=config):
|
||||
final_chunk = chunk
|
||||
|
||||
assert final_chunk is not None
|
||||
last_state = final_chunk[list(final_chunk.keys())[-1]]
|
||||
assert "Please contact IT support" in last_state["final_response"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue