Skip to content

Commit

Permalink
allow user to append own source content (#15)
Browse files Browse the repository at this point in the history
* add accordion item for creating custom source items

* add shadcn-card component

* wip: add form for custom user source

* cleanup custom source componentn

* add shadcn input component

* add components to get custom source by website url and copy/paste

* add logic for extracting pdf and page content from url

* wip: add component for rendering pdf content

* define id field on URLContent

* fetch custom sources from specified links

- supports .pdf and any public webpage

* retain formatting in web and pdf content; improve page content rendering

* cleanup

* wip: add logic to store and fetch custom sources

* add endpoint to fetch custom sources for a given audiocast session

* create a component for rendering audio_souces - ai-generated custom

* setup firebase and rxjs

* add a safe_to_dict method to db_manager

* use generate-custom-source endpoint when adding custom source

- this allows saving the custom source to firestore

* fetch custom sources on mount page/drawer

* read custom_sources from firebase using the js_sdk

* add firebase.json and optimize_deps

* parse firebase_config env variable in vite.config

* define .firebaserc

* update custom_source model; add toast notification

* consolidate and cleanup

* wip: create workflow to store copy/paste source

* add useful helpers to CustomSourceManager

* add endpoint and request logic to add copy/paste source

* improve the layouts of copypast_source and website_url_source forms

* refactor and cleanup

* use improved module names

* finalize endpoint to store uploaded content

* improve class and module names

* add ui logic for uploading dropped files

* upload dragged&dropped file as form_data; allow up to 10mb

* add a title field on source_content model

* ensure the user cannot create more than 5 custom sources

* allow uploading a file using the file dialog

* cleanup

* write source_content to db after it's generated

* add user provided custom sources to the tts_prompt

* specify the correct interface for _get_custom_sources

* improve the prompt to exclude extraneous information in user-provided sources

* add FIREBASE_CONFIG to env

* remove firebase_config from env_vars
  • Loading branch information
nwaughachukwuma authored Nov 22, 2024
1 parent 16e88aa commit f278a2d
Show file tree
Hide file tree
Showing 56 changed files with 2,055 additions and 168 deletions.
3 changes: 3 additions & 0 deletions .firebaserc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"projects": {}
}
1 change: 1 addition & 0 deletions .github/workflows/deploy_app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ env:
CONFIG_FILE: "app/app.yaml"
SERVICE: audiora-app
API_BASE_URL: ${{ secrets.API_BASE_URL }}
FIREBASE_CONFIG: ${{ secrets.FIREBASE_CONFIG }}

jobs:
prepare:
Expand Down
6 changes: 5 additions & 1 deletion api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,8 @@ uvicorn
uvloop
redis[hiredis]

async-web-search
async-web-search

lxml
beautifulsoup4
pypdf[crypto]
82 changes: 72 additions & 10 deletions api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from time import time
from typing import Any, Callable, Generator

from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi import BackgroundTasks, FastAPI, Form, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi_utilities import add_timer_middleware
Expand All @@ -13,6 +13,18 @@
SessionChatItem,
SessionChatRequest,
)
from src.utils.custom_sources.base_utils import SourceContent
from src.utils.custom_sources.extract_url_content import ExtractURLContent, ExtractURLContentRequest
from src.utils.custom_sources.generate_url_source import (
CustomSourceManager,
CustomSourceModel,
DeleteCustomSourcesRequest,
GenerateCustomSourceRequest,
GetCustomSourcesRequest,
generate_custom_source,
)
from src.utils.custom_sources.save_copied_source import CopiedPasteSourceRequest, save_copied_source
from src.utils.custom_sources.save_uploaded_sources import UploadedFiles
from src.utils.generate_audiocast import (
GenerateAudioCastRequest,
GenerateAudioCastResponse,
Expand Down Expand Up @@ -54,12 +66,16 @@ async def log_request_headers(request: Request, call_next: Callable):


@app.get("/")
async def root():
def root():
return {"message": "Hello World"}


@app.post("/chat/{session_id}", response_model=Generator[str, Any, None])
async def chat_endpoint(session_id: str, request: SessionChatRequest, background_tasks: BackgroundTasks):
def chat_endpoint(
session_id: str,
request: SessionChatRequest,
background_tasks: BackgroundTasks,
):
"""Chat endpoint"""
category = request.contentCategory
db = SessionManager(session_id, category)
Expand All @@ -85,18 +101,20 @@ async def generate_audiocast_endpoint(
request: GenerateAudioCastRequest,
background_tasks: BackgroundTasks,
):
result = await generate_audiocast(request, background_tasks)
return result
return await generate_audiocast(request, background_tasks)


@app.get("/audiocast/{session_id}", response_model=GenerateAudioCastResponse)
async def get_audiocast_endpoint(session_id: str):
def get_audiocast_endpoint(session_id: str):
result = get_audiocast(session_id)
return result


@app.post("/generate-audiocast-source", response_model=str)
async def generate_audiocast_source_endpoint(request: GenerateAudiocastSource, background_tasks: BackgroundTasks):
async def generate_audiocast_source_endpoint(
request: GenerateAudiocastSource,
background_tasks: BackgroundTasks,
):
source_content = await generate_audiocast_source(request, background_tasks)
if not source_content:
raise HTTPException(status_code=500, detail="Failed to generate source content")
Expand Down Expand Up @@ -132,6 +150,50 @@ async def get_signed_url_endpoint(blobname: str):


@app.post("/get-session-title", response_model=str)
async def get_session_title_endpoint(request: GetSessionTitleModel, background_tasks: BackgroundTasks):
source_content = await get_session_title(request, background_tasks)
return source_content
async def get_session_title_endpoint(
request: GetSessionTitleModel,
background_tasks: BackgroundTasks,
):
return await get_session_title(request, background_tasks)


@app.post("/extract-url-content", response_model=SourceContent)
def extract_url_content_endpoint(request: ExtractURLContentRequest):
extractor = ExtractURLContent()
page_content = extractor._extract(request.url)
return page_content.model_dump()


@app.post("/generate-url-source", response_model=SourceContent)
def generate_url_source_endpoint(
request: GenerateCustomSourceRequest,
background_tasks: BackgroundTasks,
):
return generate_custom_source(request, background_tasks)


@app.post("/get-custom-sources", response_model=list[CustomSourceModel])
async def get_custom_sources_endpoint(request: GetCustomSourcesRequest):
return CustomSourceManager(request.sessionId)._get_custom_sources()


@app.post("/delete-custom-source", response_model=list[CustomSourceModel])
def delete_custom_source_endpoint(request: DeleteCustomSourcesRequest):
manager = CustomSourceManager(request.sessionId)
manager._delete_custom_source(request.sourceId)
return "Deleted"


@app.post("/save-copied-source", response_model=str)
def save_copied_source_endpoint(request: CopiedPasteSourceRequest):
result = save_copied_source(request)
return result


@app.post("/save-uploaded-sources", response_model=str)
async def save_uploaded_files_endpoint(files: list[UploadFile], sessionId: str = Form(...)):
"""
Save sources uploaded from the frontend
"""
result = await UploadedFiles(session_id=sessionId)._save_sources(files)
return result
14 changes: 14 additions & 0 deletions api/src/services/firestore_sdk.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Dict, Literal

from firebase_admin.firestore import client, firestore
Expand Down Expand Up @@ -48,3 +49,16 @@ def _get_document(self, collection: Collection, doc_id: str):

def _get_documents(self, collection: Collection):
return self._get_collection(collection).stream()

@classmethod
def _safe_to_dict(cls, data: dict):
"""
safely parse firestore data by converting convert all timestamp to string
"""

def _safe_to_str(value: dict | str | datetime):
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return value

return {k: _safe_to_str(v) for k, v in data.items()}
10 changes: 4 additions & 6 deletions api/src/utils/audiocast_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,20 @@ async def _run(self):
Returns:
str: The audiocast source content
"""
source_content = await self.__use_openai(self.category, self.preference_summary)
additional_ctx = await self.get_context(self.preference_summary)
source_content = await self.__use_openai(self.category, self.preference_summary, additional_ctx)
if not source_content:
raise ValueError("Failed to generate audiocast source content")

return self._refine(source_content)

async def __use_openai(self, category: ContentCategory, preference_summary: str):
async def __use_openai(self, category: ContentCategory, preference_summary: str, additional_ctx: str):
"""
Generate audiocast source content using OpenAI.
"""
refined_summary = re.sub("You want", "A user who wants", preference_summary, flags=re.IGNORECASE)
refined_summary = re.sub("You", "A user", refined_summary, flags=re.IGNORECASE)

additional_context = await self.get_context(self.preference_summary)
print(f">>> Additional context: {additional_context}")

response = get_openai().chat.completions.create(
model="gpt-4o",
messages=[
Expand All @@ -45,7 +43,7 @@ async def __use_openai(self, category: ContentCategory, preference_summary: str)
"content": generate_source_content_prompt(
category,
refined_summary,
additional_context,
additional_ctx,
),
},
{
Expand Down
7 changes: 5 additions & 2 deletions api/src/utils/audiocast_script_maker.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
class AudioScriptMaker:
category: ContentCategory

def __init__(self, category: ContentCategory, source_content: str):
def __init__(self, category: ContentCategory, source_content: str, compiled_custom_sources: str | None = None):
self.category = category
self.source_content = source_content
self.compiled_custom_sources = compiled_custom_sources

def create(self, provider: AudioScriptProvider = "openai"):
"""
Expand All @@ -28,9 +29,11 @@ def create(self, provider: AudioScriptProvider = "openai"):
"""
print("Generating audio script...")
print(f"Category: {self.category}; Source content: {self.source_content}")
if self.compiled_custom_sources:
print(f"Custom sources: {self.compiled_custom_sources}")

prompt_maker = TTSPromptMaker(self.category, Metadata())
system_prompt = prompt_maker.get_system_prompt(self.source_content)
system_prompt = prompt_maker.get_system_prompt(self.source_content, self.compiled_custom_sources)

if provider == "anthropic":
audio_script = self.__use_anthropic(system_prompt)
Expand Down
2 changes: 1 addition & 1 deletion api/src/utils/audiocast_source_refiner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def __init__(self, category: ContentCategory, preference_summary: str):
self.category = category
self.preference_summary = preference_summary

def _refine(self, content):
def _refine(self, content: str):
"""
Moderate and augment the source content to ensure it aligns with the user's preferences.
"""
Expand Down
Empty file.
105 changes: 105 additions & 0 deletions api/src/utils/custom_sources/base_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from typing import Literal, Optional, TypedDict, cast

from google.cloud.firestore_v1 import DocumentReference
from pydantic import BaseModel

from src.services.firestore_sdk import (
Collection,
DBManager,
collections,
)


class SourceContent(BaseModel):
id: str
content: str
content_type: str
metadata: dict = {}
title: Optional[str] = None

def __str__(self):
result = f"Content: {self.content}"
if self.title:
return f"Title: {self.title}\n{result}"
return result


class CustomSourceModel(SourceContent):
source_type: Literal["link", "copy/paste", "file_upload"]
url: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None


class SourceContentDict(TypedDict):
id: str
content: str
content_type: str
metadata: dict
title: Optional[str]


class CustomSourceModelDict(SourceContentDict):
source_type: Literal["link", "copy/paste", "file_upload"]
url: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]


class CustomSourceManager(DBManager):
collection: Collection = collections["audiora_sessions"]
sub_collection = "custom_sources"

def __init__(self, session_id: str):
super().__init__()
self.doc_id = session_id

def _check_document(self):
"""if the collection does not exist, create it"""
doc = self._get_document(self.collection, self.doc_id)
if not doc.exists:
raise Exception("Session not found")
return doc

def _get_doc_ref(self, source_id: str) -> DocumentReference:
self._check_document()
return (
self._get_collection(self.collection)
.document(self.doc_id)
.collection(self.sub_collection)
.document(source_id)
)

def _set_custom_source(self, data: CustomSourceModel):
return self._get_doc_ref(data.id).set(
{
**(data.model_dump()),
"created_at": self._timestamp,
"updated_at": self._timestamp,
}
)

def _get_custom_source(self, source_id: str) -> CustomSourceModel | None:
doc = self._get_doc_ref(source_id).get()
data = doc.to_dict()
if doc.exists and data:
return cast(CustomSourceModel, self._safe_to_dict(data))

def _get_custom_sources(self) -> list[CustomSourceModelDict]:
self._check_document()

try:
session_ref = self._get_collection(self.collection).document(self.doc_id)
docs = session_ref.collection(self.sub_collection).get()
return [
cast(CustomSourceModelDict, self._safe_to_dict(doc.to_dict()))
for doc in docs
if doc.exists and doc.to_dict()
]

except Exception as e:
print(f"Error getting custom sources for Session: {self.doc_id}", e)
return []

def _delete_custom_source(self, source_id: str):
return self._get_doc_ref(source_id).delete()
Loading

0 comments on commit f278a2d

Please sign in to comment.