Skip to content

Commit

Permalink
Feat/assistant (#98)
Browse files Browse the repository at this point in the history
* 🚧 Wip(assistant): checkpoint stash before vacay

working on modularizing and utilizing "assistant templates" - for now a rewrite of LLM components
(will merge/replace with llm/ in final)

* ✨ Feat(assistant): assistant api working with rag

* ✨ Feat(assistant): change chat to use assistants

* ♻️  Refactoring(assistant): move and cleanup old chat code

* ✨ Feat(assistant): crud ops + move initial to json files

* 🎨 Style(assistant): minor comment fix
  • Loading branch information
MasterKenth authored Jun 20, 2024
1 parent f1ad54f commit 7bfb98e
Show file tree
Hide file tree
Showing 29 changed files with 646 additions and 399 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,6 @@ Pipfile.lock
pyrightconfig.json

# Project specific ignores (add your own here)
data/
src/planning_permission/data/
output/
*.xlsx
39 changes: 39 additions & 0 deletions data/assistant-templates/example-multi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"id": "multi_example",
"name": "Multi-stream Example",
"description": "This is an example assistant that showcases how an assistant can run through multiple different internal prompts before answering the user.",
"streams": [
{
"name": "First Stream",
"settings": {
"model": "gpt-4o"
},
"messages": [
{
"role": "system",
"content": "Make this text sound more fancy and verbose."
},
{
"role": "user",
"content": "{query}"
}
]
},
{
"name": "Second Stream",
"settings": {
"model": "gpt-4o"
},
"messages": [
{
"role": "system",
"content": "Repeat back any text verbatim and count the number of words"
},
{
"role": "user",
"content": "{last_input}"
}
]
}
]
}
24 changes: 24 additions & 0 deletions data/assistant-templates/example-rag.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"id": "rag_example",
"name": "RAG (document) Example",
"files_collection_id": "Upload files in the Assistant Creation UI",
"description": "This is an example assistant that showcases how to setup an assistant for answering questions about uploaded documents. \n\nMake sure to upload documents first.",
"streams": [
{
"name": "RagStream",
"settings": {
"model": "gpt-4o"
},
"messages": [
{
"role": "system",
"content": "Explain what this is and repeat back an excerpt of it."
},
{
"role": "user",
"content": "{rag_results}"
}
]
}
]
}
28 changes: 28 additions & 0 deletions data/assistant-templates/example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"id": "example",
"name": "Example Assistant",
"description": "This is an *example assistant* for testing purposes.",
"sample_questions": [
"Hur kan jag kolla upp tidstabellen för bussar?",
"Vilka evenemang händer i sommar?",
"Var kan jag parkera?"
],
"streams": [
{
"name": "ChatStream",
"settings": {
"model": "gpt-4o"
},
"messages": [
{
"role": "system",
"content": "You are a helpful AI assistant that helps people with answering questions related to municipality and Helsingborg City. The questions are going to be asked in Swedish. Your response must always be in Swedish."
},
{
"role": "user",
"content": "{query}"
}
]
}
]
}
28 changes: 28 additions & 0 deletions data/assistant-templates/planning-permission.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"id": "planning",
"name": "Planning Assistant",
"files_collection_id": "Upload files in the Assistant Creation UI",
"description": "Den här assistenten är expert på att svara på frågor angående bygglov i Helsingborg Stad.",
"streams": [
{
"name": "ChatStream",
"settings": {
"model": "gpt-4o"
},
"messages": [
{
"role": "system",
"content": "You are a helpful AI assistant that helps people with answering questions about planning permission.<br> If you can't find the answer in the search result below, just say (in Swedish) \"Tyvärr kan jag inte svara på det.\" Don't try to make up an answer.<br> If the question is not related to the context, politely respond that you are tuned to only answer questions that are related to the context.<br> The questions are going to be asked in Swedish. Your response must always be in Swedish."
},
{
"role": "user",
"content": "{query}"
},
{
"role": "user",
"content": "Here are the results of the search:\n\n {rag_results}"
}
]
}
]
}
53 changes: 53 additions & 0 deletions fai-rag-app/fai-backend/fai_backend/assistant/assistant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from typing import Any

from langstream import Stream

from fai_backend.assistant.models import AssistantTemplate
from fai_backend.assistant.protocol import ILLMProtocol, IAssistantStreamProtocol
from fai_backend.llm.service import create_rag_stream


class AssistantLLM(ILLMProtocol):
def __init__(self, template: AssistantTemplate, base_stream: IAssistantStreamProtocol):
self.template = template
self.base_stream = base_stream
self.vars = {}

async def create(self) -> Stream[str, Any]:
all_stream_producers = [self.base_stream.create_stream(stream_def, lambda: self.vars) for
stream_def in self.template.streams]

chained_stream: Stream[str, Any] | None = None

if self.template.files_collection_id:
chained_stream = await self._create_rag_stream()

for stream_producer in all_stream_producers:
chained_stream = chained_stream.and_then(await stream_producer) \
if chained_stream is not None \
else await stream_producer

def preprocess(initial_query: str):
self.vars.update({"query": initial_query})
return initial_query

return (
Stream[str, str]('preprocess', preprocess)
.and_then(chained_stream)
)

async def _create_rag_stream(self) -> Stream[str, str]:
async def run_rag_stream(initial_query: list[str]):
stream = await create_rag_stream(initial_query[0], self.template.files_collection_id)
async for r in stream(initial_query[0]):
yield r

def rag_postprocess(in_data: Any):
results = in_data[0]['results']
self.vars.update({'rag_results': results})
return self.vars['query']

return (
Stream('RAGStream', run_rag_stream)
.and_then(rag_postprocess)
)
40 changes: 40 additions & 0 deletions fai-rag-app/fai-backend/fai_backend/assistant/assistant_openai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from typing import Callable, Any, Iterable

from langstream import Stream
from langstream.contrib import OpenAIChatStream, OpenAIChatDelta, OpenAIChatMessage

from fai_backend.assistant.models import LLMStreamDef, LLMStreamMessage
from fai_backend.assistant.protocol import IAssistantStreamProtocol


class OpenAIAssistantStream(IAssistantStreamProtocol):
async def create_stream(self, stream_def: LLMStreamDef, get_vars: Callable[[], dict]) -> Stream[str, str]:
return OpenAIChatStream[str, OpenAIChatDelta](
stream_def.name,
lambda in_data: self._to_openai_messages(in_data, stream_def.messages, get_vars),
model=stream_def.settings.model,
temperature=getattr(stream_def, 'settings.temperature', 0),
functions=getattr(stream_def, 'functions', None),
function_call=getattr(stream_def, 'function_call', None),
).map(lambda delta: delta.content)

@staticmethod
def _to_openai_messages(
in_data: Any,
messages: list[LLMStreamMessage],
get_vars: Callable[[], dict]
) -> Iterable[OpenAIChatMessage]:
def parse_in_data(data: Any):
if isinstance(data, list):
return "".join([parse_in_data(c) for c in data])
return str(data)

in_data_as_str = parse_in_data(in_data)

input_vars = get_vars()
input_vars['last_input'] = in_data_as_str

return [OpenAIChatMessage(
content=message.content.format(**input_vars),
role=message.role
) for message in messages]
37 changes: 37 additions & 0 deletions fai-rag-app/fai-backend/fai_backend/assistant/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from datetime import datetime
from typing import Optional, Dict, List, Any, Literal, Union

from pydantic import BaseModel


class LLMStreamMessage(BaseModel):
role: Literal["system", "user", "assistant", "function"]
content: str


class LLMStreamSettings(BaseModel):
model: str
temperature: Optional[float] = 0
functions: Optional[List[Dict[str, Any]]] = None
function_call: Optional[Union[Literal["none", "auto"], Dict[str, Any]]] = None


class LLMStreamDef(BaseModel):
name: str
settings: LLMStreamSettings
messages: Optional[List[LLMStreamMessage]] = None


class AssistantTemplate(BaseModel):
id: str
name: str
files_collection_id: Optional[str] = None
description: Optional[str] = None
sample_questions: list[str] = []
streams: List[LLMStreamDef]


class LLMClientChatMessage(BaseModel):
date: datetime
source: str | None = None
content: str | None = None
19 changes: 19 additions & 0 deletions fai-rag-app/fai-backend/fai_backend/assistant/protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import Protocol, Callable

from langstream import Stream

from fai_backend.assistant.models import LLMStreamDef


class ILLMProtocol(Protocol):
async def create(self) -> Stream[str, str]:
"""
Create a Stream that takes a str (generally a question) and returns
a stream of tokens (strings) of the response given by the LLM.
"""
...


class IAssistantStreamProtocol(Protocol):
async def create_stream(self, stream_def: LLMStreamDef, get_vars: Callable[[], dict]) -> Stream[str, str]:
...
89 changes: 89 additions & 0 deletions fai-rag-app/fai-backend/fai_backend/assistant/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from fastapi import APIRouter, Depends, Security
from langstream import join_final_output

from fai_backend.assistant.models import AssistantTemplate
from fai_backend.assistant.service import AssistantFactory
from fai_backend.dependencies import get_authenticated_user
from fai_backend.logger.route_class import APIRouter as LoggingAPIRouter
from fai_backend.projects.dependencies import list_projects_request, get_project_request, get_project_service, \
update_project_request
from fai_backend.projects.schema import ProjectResponse, ProjectUpdateRequest
from fai_backend.projects.service import ProjectService

router = APIRouter(
prefix='/api',
tags=['Assistant'],
route_class=LoggingAPIRouter,
dependencies=[],
)


@router.get(
'/assistant/{project_id}/ask/{assistant_id}',
summary="Ask an assistant a question.",
dependencies=[Security(get_authenticated_user)]
)
async def ask_assistant(
project_id: str,
assistant_id: str,
question: str,
projects: list[ProjectResponse] = Depends(list_projects_request),
):
print(f"Assistant: {project_id}/{assistant_id} - {question}")
factory = AssistantFactory([a for p in projects for a in p.assistants if p.id == project_id])
assistant = factory.create_assistant_stream(assistant_id)
stream = await assistant.create()
return await join_final_output(stream(question))


@router.get(
'/assistant/{project_id}/template',
summary="Get assistant templates.",
response_model=list[AssistantTemplate],
dependencies=[Security(get_authenticated_user)]
)
async def get_template(
project_id: str,
projects: list[ProjectResponse] = Depends(list_projects_request)
):
return [a for p in projects for a in p.assistants if p.id == project_id]


@router.post(
'/assistant/{project_id}/template',
summary="Create/update assistant template.",
response_model=AssistantTemplate,
dependencies=[Security(get_authenticated_user)]
)
async def create_template(
template: AssistantTemplate,
existing_project: ProjectResponse = Depends(get_project_request),
project_service: ProjectService = Depends(get_project_service),
):
existing_project.assistants = [a for a in existing_project.assistants if a.id != template.id]
existing_project.assistants.append(template)
await update_project_request(
body=ProjectUpdateRequest(**existing_project.model_dump()),
existing_project=existing_project,
project_service=project_service)
return template


@router.delete(
'/assistant/{project_id}/template/{assistant_id}',
summary="Delete assistant template.",
response_model=AssistantTemplate | None,
dependencies=[Security(get_authenticated_user)]
)
async def delete_template(
assistant_id: str,
existing_project: ProjectResponse = Depends(get_project_request),
project_service: ProjectService = Depends(get_project_service)
):
assistant = next((a for a in existing_project.assistants if a.id == assistant_id), None)
existing_project.assistants = [a for a in existing_project.assistants if a.id != assistant_id]
await update_project_request(
body=ProjectUpdateRequest(**existing_project.model_dump()),
existing_project=existing_project,
project_service=project_service)
return assistant
20 changes: 20 additions & 0 deletions fai-rag-app/fai-backend/fai_backend/assistant/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from fai_backend.assistant.assistant import AssistantLLM
from fai_backend.assistant.assistant_openai import OpenAIAssistantStream
from fai_backend.assistant.models import AssistantTemplate
from fai_backend.config import settings
from fai_backend.assistant.protocol import IAssistantStreamProtocol


class AssistantFactory:
def __init__(self, assistant_templates: list[AssistantTemplate]):
self.assistant_templates = assistant_templates

def create_assistant_stream(self, assistant_id: str, backend: str = settings.LLM_BACKEND) -> AssistantLLM:
assistant = next(a for a in self.assistant_templates if a.id == assistant_id)
return AssistantLLM(assistant, self._get_stream_constructor(backend))

@staticmethod
def _get_stream_constructor(backend: str) -> IAssistantStreamProtocol:
return {
'openai': lambda: OpenAIAssistantStream(),
}[backend]()
Loading

0 comments on commit 7bfb98e

Please sign in to comment.