From 72219100ee5405fe5b2eac662dc12b0e1484e1d6 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Sat, 17 May 2025 17:27:46 -0700 Subject: [PATCH] Multi-user almost working --- docker-compose.yml | 6 +- frontend/src/App/Main.tsx | 7 +- frontend/src/App/SessionWrapper.tsx | 50 +++- frontend/src/Components/Message.tsx | 2 - frontend/src/Components/VectorVisualizer.tsx | 15 +- frontend/src/Pages/ControlsPage.tsx | 37 +-- frontend/src/Pages/HomePage.tsx | 86 ++++-- src/server.py | 272 +++++++++--------- src/tests/test-context.py | 19 +- src/utils/__init__.py | 7 +- src/utils/agents/base.py | 4 +- src/utils/agents/job_description.py | 43 ++- src/utils/context.py | 138 ++++----- src/utils/defines.py | 15 +- src/utils/rag.py | 63 ++-- src/utils/user.py | 216 ++++++++++++++ users-prod/.keep | 0 users/.keep | 0 users/eliza/info.json | 4 + .../jobs/plant-conrervation-specialist.md | 63 ++++ .../rag-content/jobs/research-assistant.md | 75 +++++ .../rag-content/jobs/restoration-botanist.md | 55 ++++ .../resume/Eliza_Morgan_Resume.docx | Bin 0 -> 38520 bytes .../rag-content/resume/Eliza_Morgan_Resume.md | 91 ++++++ 24 files changed, 870 insertions(+), 398 deletions(-) create mode 100644 src/utils/user.py create mode 100644 users-prod/.keep create mode 100644 users/.keep create mode 100644 users/eliza/info.json create mode 100644 users/eliza/rag-content/jobs/plant-conrervation-specialist.md create mode 100644 users/eliza/rag-content/jobs/research-assistant.md create mode 100644 users/eliza/rag-content/jobs/restoration-botanist.md create mode 100755 users/eliza/rag-content/resume/Eliza_Morgan_Resume.docx create mode 100644 users/eliza/rag-content/resume/Eliza_Morgan_Resume.md diff --git a/docker-compose.yml b/docker-compose.yml index 5de9168..e712bb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,11 +21,10 @@ services: ports: - 8912:8911 # FastAPI React server volumes: - - ./cache:/root/.cache # Persist all models and GPU kernel cache + - ./cache:/root/.cache # Persist all models and GPU kernel cache - ./sessions:/opt/backstory/sessions:rw # Persist sessions - - ./chromadb:/opt/backstory/chromadb:rw # Persist ChromaDB - ./dev-keys:/opt/backstory/keys:ro # Developer keys - - ./docs:/opt/backstory/docs:rw # Live mount of RAG content + - ./users:/opt/backstory/users:rw # Live mount of user data - ./src:/opt/backstory/src:rw # Live mount server src cap_add: # used for running ze-monitor within container - CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks @@ -58,6 +57,7 @@ services: - ./chromadb-prod:/opt/backstory/chromadb:rw # Persist ChromaDB - ./sessions-prod:/opt/backstory/sessions:rw # Persist sessions - ./docs-prod:/opt/backstory/docs:rw # Live mount of RAG content + - ./users-prod:/opt/backstory/users:rw # Live mount of user data - ./frontend/deployed:/opt/backstory/frontend/deployed:ro # Live mount built frontend cap_add: # used for running ze-monitor within container - CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks diff --git a/frontend/src/App/Main.tsx b/frontend/src/App/Main.tsx index f3c649a..6f58dbf 100644 --- a/frontend/src/App/Main.tsx +++ b/frontend/src/App/Main.tsx @@ -137,7 +137,7 @@ const Main = (props: MainProps) => { ) }; - if (sessionId === undefined) { + if (sessionId === undefined || !sessionId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) { return [loadingTab]; } else { return [ @@ -183,6 +183,9 @@ const Main = (props: MainProps) => { }; useEffect(() => { + if (sessionId === undefined || !sessionId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) { + return; + } const pathParts = window.location.pathname.split('/').filter(Boolean); const currentPath = pathParts.length < 2 ? '' : pathParts[0]; let currentSubRoute = pathParts.length > 2 ? pathParts.slice(1, -1).join('/') : ''; @@ -197,7 +200,7 @@ const Main = (props: MainProps) => { setTab(tabs[tabIndex]); setSubRoute(currentSubRoute); console.log(`Initial load set to tab ${tabs[tabIndex].path} subRoute: ${currentSubRoute}`); - }, [tabs]); + }, [tabs, sessionId]); useEffect(() => { if (tab === undefined || sessionId === undefined) { diff --git a/frontend/src/App/SessionWrapper.tsx b/frontend/src/App/SessionWrapper.tsx index e9cca85..4d8a916 100644 --- a/frontend/src/App/SessionWrapper.tsx +++ b/frontend/src/App/SessionWrapper.tsx @@ -3,8 +3,12 @@ import { useNavigate, useLocation } from "react-router-dom"; import { connectionBase } from '../Global'; import { SetSnackType } from '../Components/Snack'; -const getSessionId = async () => { - const response = await fetch(connectionBase + `/api/context`, { +const getSessionId = async (userId?: string) => { + const endpoint = userId + ? `/api/context/u/${encodeURIComponent(userId)}` + : `/api/context`; + + const response = await fetch(connectionBase + endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -34,9 +38,27 @@ const SessionWrapper = ({ setSnack, children }: SessionWrapperProps) => { const [retry, setRetry] = useState(0); useEffect(() => { + console.log(`SessionWrapper: ${location.pathname}`); + const ensureSessionId = async () => { const parts = location.pathname.split("/").filter(Boolean); const pattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89ab][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/i; + + // Case: path starts with "u/{USERID}" + if (parts.length >= 2 && parts[0] === "u") { + const userId = parts[1]; + + // Case: "u/{USERID}" - fetch session for this user + const activeSession = await getSessionId(userId); + setSessionId(activeSession); + + // Append session to path + const newPath = [...parts, activeSession].join("/"); + navigate(`/${activeSession}`, { replace: true }); + return; + } + + // Default case (original behavior) const hasSession = parts.length !== 0 && pattern.test(parts[parts.length - 1]); if (!hasSession) { @@ -53,18 +75,24 @@ const SessionWrapper = ({ setSnack, children }: SessionWrapperProps) => { if (!fetchingRef.current) { fetchingRef.current = true; - ensureSessionId().catch((e) => { - console.error(e); - setSnack("Backstory is temporarily unavailable. Retrying in 5 seconds.", "warning"); - setTimeout(() => { - fetchingRef.current = false; - setRetry(retry => retry + 1) - }, 5000); - }); + ensureSessionId() + .catch((e) => { + console.error(e); + setSnack("Backstory is temporarily unavailable. Retrying in 5 seconds.", "warning"); + setTimeout(() => { + fetchingRef.current = false; + setRetry(retry => retry + 1); + }, 5000); + }) + .finally(() => { + if (fetchingRef.current) { + fetchingRef.current = false; + } + }); } }, [location.pathname, navigate, setSnack, sessionId, retry]); return <>{children}; }; -export { SessionWrapper }; +export { SessionWrapper }; \ No newline at end of file diff --git a/frontend/src/Components/Message.tsx b/frontend/src/Components/Message.tsx index 250ac8e..ec59c71 100644 --- a/frontend/src/Components/Message.tsx +++ b/frontend/src/Components/Message.tsx @@ -117,8 +117,6 @@ const MessageMeta = (props: MessageMetaProps) => { } = props.metadata || {}; const message: any = props.messageProps.message; - console.log(tools, rag); - let llm_submission: string = "<|system|>\n" llm_submission += message.system_prompt + "\n\n" llm_submission += message.context_prompt diff --git a/frontend/src/Components/VectorVisualizer.tsx b/frontend/src/Components/VectorVisualizer.tsx index 92f2056..df5eb34 100644 --- a/frontend/src/Components/VectorVisualizer.tsx +++ b/frontend/src/Components/VectorVisualizer.tsx @@ -163,7 +163,7 @@ const colorMap: Record = { resume: '#4A7A7D', // Dusty Teal — secondary theme color projects: '#1A2536', // Midnight Blue — rich and deep news: '#D3CDBF', // Warm Gray — soft and neutral - 'performance-reviews': '#FFD0D0', // Light red + 'performance-reviews': '#8FD0D0', // Light red 'jobs': '#F3aD8F', // Warm Gray — soft and neutral }; @@ -214,7 +214,7 @@ const VectorVisualizer: React.FC = (props: VectorVisualiz const plotContainerRect = plotContainer.getBoundingClientRect(); svgContainer.style.width = `${plotContainerRect.width}px`; svgContainer.style.height = `${plotContainerRect.height}px`; - if (plotDimensions.width !== plotContainerRect.width || plotDimensions.height != plotContainerRect.height) { + if (plotDimensions.width !== plotContainerRect.width || plotDimensions.height !== plotContainerRect.height) { setPlotDimensions({ width: plotContainerRect.width, height: plotContainerRect.height }); } } @@ -347,6 +347,7 @@ const VectorVisualizer: React.FC = (props: VectorVisualiz size: filtered_sizes, symbol: 'circle', color: filtered_colors, + opacity: 1 }, text: filtered.ids, customdata: filtered.metadatas, @@ -361,6 +362,7 @@ const VectorVisualizer: React.FC = (props: VectorVisualiz size: query_sizes, symbol: 'circle', color: query_colors, + opacity: 1 }, text: query.ids, customdata: query.metadatas, @@ -473,13 +475,6 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? ' @@ -513,7 +508,7 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? ' control={} onChange={() => setView2D(!view2D)} label="3D" /> { console.log("click"); onNodeSelected(event.points[0].customdata); }} + onClick={(event: any) => { onNodeSelected(event.points[0].customdata); }} data={plotData} useResizeHandler={true} config={config} diff --git a/frontend/src/Pages/ControlsPage.tsx b/frontend/src/Pages/ControlsPage.tsx index b961f64..2c2d94a 100644 --- a/frontend/src/Pages/ControlsPage.tsx +++ b/frontend/src/Pages/ControlsPage.tsx @@ -19,7 +19,6 @@ import { BackstoryPageProps } from '../Components/BackstoryTab'; interface ServerTunables { system_prompt: string, - message_history_length: number, tools: Tool[], rags: Tool[] }; @@ -125,38 +124,7 @@ const ControlsPage = (props: BackstoryPageProps) => { }, [systemPrompt, sessionId, setSnack, serverTunables]); - useEffect(() => { - if (serverTunables === undefined || messageHistoryLength === serverTunables.message_history_length || !messageHistoryLength || sessionId === undefined) { - return; - } - const sendMessageHistoryLength = async (length: number) => { - try { - const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ "message_history_length": length }), - }); - - const data = await response.json(); - const newLength = data["message_history_length"]; - if (newLength !== messageHistoryLength) { - setMessageHistoryLength(newLength); - setSnack("Message history length updated", "success"); - } - } catch (error) { - console.error('Fetch error:', error); - setSnack("Message history length update failed", "error"); - } - }; - - sendMessageHistoryLength(messageHistoryLength); - - }, [messageHistoryLength, setMessageHistoryLength, sessionId, setSnack, serverTunables]); - - const reset = async (types: ("rags" | "tools" | "history" | "system_prompt" | "message_history_length")[], message: string = "Update successful.") => { + const reset = async (types: ("rags" | "tools" | "history" | "system_prompt")[], message: string = "Update successful.") => { try { const response = await fetch(connectionBase + `/api/reset/${sessionId}`, { method: 'PUT', @@ -308,7 +276,6 @@ const ControlsPage = (props: BackstoryPageProps) => { // console.log("Server tunables: ", data); setServerTunables(data); setSystemPrompt(data["system_prompt"]); - setMessageHistoryLength(data["message_history_length"]); setTools(data["tools"]); setRags(data["rags"]); } catch (error) { @@ -453,7 +420,7 @@ const ControlsPage = (props: BackstoryPageProps) => { {/* - */} + */} ); } diff --git a/frontend/src/Pages/HomePage.tsx b/frontend/src/Pages/HomePage.tsx index c07daf9..6e72ebd 100644 --- a/frontend/src/Pages/HomePage.tsx +++ b/frontend/src/Pages/HomePage.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef } from 'react'; +import React, { forwardRef, useEffect, useState } from 'react'; import useMediaQuery from '@mui/material/useMediaQuery'; import Box from '@mui/material/Box'; import { useTheme } from '@mui/material/styles'; @@ -9,45 +9,85 @@ import { Conversation, ConversationHandle } from '../Components/Conversation'; import { ChatQuery } from '../Components/ChatQuery'; import { MessageList } from '../Components/Message'; +import { connectionBase } from '../Global'; + +type UserData = { + user_name: string; + first_name: string; + last_name: string; + full_name: string; + contact_info: Record; + questions: string[]; +}; + const HomePage = forwardRef((props: BackstoryPageProps, ref) => { const { sessionId, setSnack, submitQuery } = props; const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const [preamble, setPreamble] = useState([]); + const [questions, setQuestions] = useState([]); + const [user, setUser] = useState(undefined) - if (sessionId === undefined) { - return <>; - } + useEffect(() => { + if (user === undefined) { + return; + } - const backstoryPreamble: MessageList = [ - { + setPreamble([{ role: 'content', title: 'Welcome to Backstory', disableCopy: true, content: ` Backstory is a RAG enabled expert system with access to real-time data running self-hosted (no cloud) versions of industry leading Large and Small Language Models (LLM/SLMs). -It was written by James Ketrenos in order to provide answers to -questions potential employers may have about his work history. -What would you like to know about James? +This instances has been launched for ${user.full_name}. + +What would you like to know about ${user.first_name}? `, - } - ]; + }]); - const backstoryQuestions = [ - - - - - + setQuestions([ + + {user.questions.map((q: string, i: number) => + + )} , - As with all LLM interactions, the results may not be 100% accurate. If you have questions about my career, - I'd love to hear from you. You can send me an email at **james_backstory@ketrenos.com**. + {`As with all LLM interactions, the results may not be 100% accurate. Please contact **${user.full_name}** if you have any questions.`} - - ]; + ]); + }, [user, isMobile, submitQuery]); + + useEffect(() => { + const fetchUserData = async () => { + try { + const response = await fetch(connectionBase + `/api/user/${sessionId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Server responded with ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + setUser(data); + } + catch (error) { + console.error('Error getting user info:', error); + setSnack("Unable to obtain user information.", "error"); + } + }; + fetchUserData(); + }, [setSnack, sessionId]); + + if (sessionId === undefined || user === undefined) { + return <>; + } return ; diff --git a/src/server.py b/src/server.py index d650275..ed9cac4 100644 --- a/src/server.py +++ b/src/server.py @@ -21,10 +21,8 @@ import subprocess import re import math import warnings -from typing import Any -from datetime import datetime +# from typing import Any import inspect -from uuid import uuid4 import time import traceback @@ -63,7 +61,7 @@ from prometheus_client import CollectorRegistry, Counter # type: ignore from utils import ( rag as Rag, - ChromaDBGetResponse, + RagEntry, tools as Tools, Context, Conversation, @@ -72,26 +70,15 @@ from utils import ( Metrics, Tunables, defines, + User, check_serializable, logger, ) - -rags : List[ChromaDBGetResponse] = [ - ChromaDBGetResponse( - name="JPK", - enabled=True, - description="Expert data about James Ketrenos, including work history, personal hobbies, and projects.", - ), - # { "name": "LKML", "enabled": False, "description": "Full associative data for entire LKML mailing list archive." }, -] - - class Query(BaseModel): prompt: str tunables: Tunables = Field(default_factory=Tunables) - agent_options: Dict[str, Any] = Field(default={}) - + agent_options: Dict[str, str | int | float | Dict] = Field(default={}, exclude=True) REQUEST_TIME = Summary("request_processing_seconds", "Time spent processing request") @@ -182,12 +169,6 @@ WEB_HOST = "0.0.0.0" WEB_PORT = 8911 DEFAULT_HISTORY_LENGTH = 5 -# %% -# Globals - -model = None -web_server = None - # %% # Cmd line overrides @@ -246,19 +227,11 @@ def is_valid_uuid(value: str) -> bool: class WebServer: @asynccontextmanager async def lifespan(self, app: FastAPI): - # Start the file watcher - self.observer, self.file_watcher = Rag.start_file_watcher( - llm=self.llm, - watch_directory=defines.doc_dir, - recreate=False, # Don't recreate if exists - ) - logger.info( - f"API started with {self.file_watcher.collection.count()} documents in the collection" - ) yield - if self.observer: - self.observer.stop() - self.observer.join() + for user in self.users: + if user.observer: + user.observer.stop() + user.observer.join() logger.info("File watcher stopped") def __init__(self, llm, model=MODEL_NAME): @@ -276,13 +249,12 @@ class WebServer: # Expose the /metrics endpoint self.instrumentator.expose(self.app, endpoint="/metrics") - self.contexts = {} - self.llm = llm self.model = model self.processing = False - self.file_watcher = None - self.observer = None + + self.users = [] + self.contexts = {} self.ssl_enabled = os.path.exists(defines.key_path) and os.path.exists( defines.cert_path @@ -308,7 +280,7 @@ class WebServer: def setup_routes(self): @self.app.get("/") async def root(): - context = self.create_context() + context = self.create_context(username=defines.default_username) logger.info(f"Redirecting non-context to {context.id}") return RedirectResponse(url=f"/{context.id}", status_code=307) # return JSONResponse({"redirect": f"/{context.id}"}) @@ -317,15 +289,14 @@ class WebServer: async def get_umap(doc_id: str, context_id: str, request: Request): logger.info(f"{request.method} {request.url.path}") try: - if not self.file_watcher: - raise Exception("File watcher not initialized") - context = self.upsert_context(context_id) if not context: return JSONResponse( {"error": f"Invalid context: {context_id}"}, status_code=400 ) - collection = self.file_watcher.umap_collection + + user = context.user + collection = user.umap_collection if not collection: return JSONResponse( {"error": "No UMAP collection found"}, status_code=404 @@ -336,7 +307,7 @@ class WebServer: for index, id in enumerate(collection.get("ids", [])): if id == doc_id: metadata = collection.get("metadatas", [])[index].copy() - content = self.file_watcher.prepare_metadata(metadata) + content = user.file_watcher.prepare_metadata(metadata) return JSONResponse(content) return JSONResponse(f"Document id {doc_id} not found.", 404) @@ -349,29 +320,25 @@ class WebServer: async def put_umap(context_id: str, request: Request): logger.info(f"{request.method} {request.url.path}") try: - if not self.file_watcher: - raise Exception("File watcher not initialized") - context = self.upsert_context(context_id) if not context: return JSONResponse( {"error": f"Invalid context: {context_id}"}, status_code=400 ) - + user = context.user data = await request.json() - dimensions = data.get("dimensions", 2) - collection = self.file_watcher.umap_collection + collection = user.file_watcher.umap_collection if not collection: return JSONResponse( {"error": "No UMAP collection found"}, status_code=404 ) if dimensions == 2: logger.info("Returning 2D UMAP") - umap_embedding = self.file_watcher.umap_embedding_2d + umap_embedding = user.file_watcher.umap_embedding_2d else: logger.info("Returning 3D UMAP") - umap_embedding = self.file_watcher.umap_embedding_3d + umap_embedding = user.file_watcher.umap_embedding_3d if len(umap_embedding) == 0: return JSONResponse( @@ -382,26 +349,21 @@ class WebServer: "metadatas": collection.get("metadatas", []), "documents": collection.get("documents", []), "embeddings": umap_embedding.tolist(), - "size": self.file_watcher.collection.count() + "size": user.file_watcher.collection.count() } return JSONResponse(result) except Exception as e: - logger.error(f"put_umap error: {str(e)}") logger.error(traceback.format_exc()) + logger.error(f"put_umap error: {str(e)}") return JSONResponse({"error": str(e)}, 500) @self.app.put("/api/similarity/{context_id}") async def put_similarity(context_id: str, request: Request): logger.info(f"{request.method} {request.url.path}") - if not self.file_watcher: - raise Exception("File watcher not initialized") - - if not is_valid_uuid(context_id): - logger.warning(f"Invalid context_id: {context_id}") - return JSONResponse({"error": "Invalid context_id"}, status_code=400) - + context = self.upsert_context(context_id) + user = context.user try: data = await request.json() query = data.get("query", "") @@ -417,7 +379,7 @@ class WebServer: status_code=400, ) try: - chroma_results = self.file_watcher.find_similar( + chroma_results = user.file_watcher.find_similar( query=query, top_k=results, threshold=threshold ) if not chroma_results: @@ -428,14 +390,14 @@ class WebServer: ).flatten() # Ensure correct shape logger.info(f"Chroma embedding shape: {chroma_embedding.shape}") - umap_2d = self.file_watcher.umap_model_2d.transform([chroma_embedding])[ + umap_2d = user.file_watcher.umap_model_2d.transform([chroma_embedding])[ 0 ].tolist() logger.info( f"UMAP 2D output: {umap_2d}, length: {len(umap_2d)}" ) # Debug output - umap_3d = self.file_watcher.umap_model_3d.transform([chroma_embedding])[ + umap_3d = user.file_watcher.umap_model_3d.transform([chroma_embedding])[ 0 ].tolist() logger.info( @@ -449,7 +411,7 @@ class WebServer: "query": query, "umap_embedding_2d": umap_2d, "umap_embedding_3d": umap_3d, - "size": self.file_watcher.collection.count() + "size": user.file_watcher.collection.count() }) except Exception as e: @@ -478,7 +440,7 @@ class WebServer: logger.info(f"Resetting {reset_operation}") case "rags": logger.info(f"Resetting {reset_operation}") - context.rags = [ r.model_copy() for r in rags] + context.rags = [ r.model_copy() for r in context.user.rags] response["rags"] = [ r.model_dump(mode="json") for r in context.rags ] case "tools": logger.info(f"Resetting {reset_operation}") @@ -511,10 +473,6 @@ class WebServer: tmp.conversation.reset() response["history"] = [] response["context_used"] = agent.context_tokens - case "message_history_length": - logger.info(f"Resetting {reset_operation}") - context.message_history_length = DEFAULT_HISTORY_LENGTH - response["message_history_length"] = DEFAULT_HISTORY_LENGTH if not response: return JSONResponse( @@ -548,6 +506,7 @@ class WebServer: for k in data.keys(): match k: case "tools": + from typing import Any # { "tools": [{ "tool": tool?.name, "enabled": tool.enabled }] } tools: list[dict[str, Any]] = data[k] if not tools: @@ -575,6 +534,7 @@ class WebServer: ) case "rags": + from typing import Any # { "rags": [{ "tool": tool?.name, "enabled": tool.enabled }] } rag_configs: list[dict[str, Any]] = data[k] if not rag_configs: @@ -603,11 +563,6 @@ class WebServer: agent.system_prompt = system_prompt self.save_context(context_id) return JSONResponse({"system_prompt": system_prompt}) - case "message_history_length": - value = max(0, int(data[k])) - context.message_history_length = value - self.save_context(context_id) - return JSONResponse({"message_history_length": value}) case _: return JSONResponse( {"error": f"Unrecognized tunable {k}"}, status_code=404 @@ -616,6 +571,20 @@ class WebServer: logger.error(f"Error in put_tunables: {e}") return JSONResponse({"error": str(e)}, status_code=500) + @self.app.get("/api/user/{context_id}") + async def get_user(context_id: str, request: Request): + logger.info(f"{request.method} {request.url.path}") + user = self.upsert_context(context_id).user + user_data = { + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "full_name": user.full_name, + "contact_info": user.contact_info, + "questions": user.user_questions, + } + return JSONResponse(user_data) + @self.app.get("/api/tunables/{context_id}") async def get_tunables(context_id: str, request: Request): logger.info(f"{request.method} {request.url.path}") @@ -630,7 +599,6 @@ class WebServer: return JSONResponse( { "system_prompt": agent.system_prompt, - "message_history_length": context.message_history_length, "rags": [ r.model_dump(mode="json") for r in context.rags ], "tools": [ { @@ -753,8 +721,8 @@ class WebServer: await asyncio.sleep(0) except Exception as e: context.processing = False - logger.error(f"Error in generate_response: {e}") logger.error(traceback.format_exc()) + logger.error(f"Error in generate_response: {e}") yield json.dumps({"status": "error", "response": str(e)}) + "\n" finally: # Save context on completion or error @@ -775,16 +743,25 @@ class WebServer: logger.error(f"Error in post_chat_endpoint: {e}") return JSONResponse({"error": str(e)}, status_code=500) - @self.app.post("/api/context") - async def create_context(): + @self.app.post("/api/context/u/{username}") + async def create_user_context(username: str, request: Request): + logger.info(f"{request.method} {request.url.path}") try: - context = self.create_context() - logger.info(f"Generated new agent as {context.id}") + if not User.exists(username): + return JSONResponse({"error": f"User {username} not found."}, status_code=404) + + context = self.create_context(username=username) + logger.info(f"Generated new context {context.id} for {username}") return JSONResponse({"id": context.id}) except Exception as e: - logger.error(f"get_history error: {str(e)}") + logger.error(f"create_user_context error: {str(e)}") logger.error(traceback.format_exc()) - return JSONResponse({"error": str(e)}, status_code=404) + return JSONResponse({"error": f"User {username} not found."}, status_code=404) + + @self.app.post("/api/context") + async def create_context(request: Request): + logger.info(f"{request.method} {request.url.path}") + return self.app.create_user_context(defines.default_username, request) @self.app.get("/api/history/{context_id}/{agent_type}") async def get_history(context_id: str, agent_type: str, request: Request): @@ -802,10 +779,8 @@ class WebServer: ) return agent.conversation except Exception as e: - logger.error(f"get_history error: {str(e)}") - import traceback - logger.error(traceback.format_exc()) + logger.error(f"get_history error: {str(e)}") return JSONResponse({"error": str(e)}, status_code=404) @self.app.get("/api/tools/{context_id}") @@ -922,7 +897,7 @@ class WebServer: return context_id - def load_or_create_context(self, context_id) -> Context: + def load_or_create_context(self, context_id: str) -> Context: """ Load a context from a file in the context directory or create a new one if it doesn't exist. Args: @@ -930,15 +905,12 @@ class WebServer: Returns: A Context object with the specified ID and default settings. """ - if not self.file_watcher: - raise Exception("File watcher not initialized") - file_path = os.path.join(defines.context_dir, context_id) # Check if the file exists if not os.path.exists(file_path): logger.info(f"Context file {file_path} not found. Creating new context.") - self.contexts[context_id] = self.create_context(context_id) + self.contexts[context_id] = self.create_context(username=defines.default_username, context_id=context_id) else: # Read and deserialize the data with open(file_path, "r") as f: @@ -946,19 +918,25 @@ class WebServer: logger.info( f"Loading context from {file_path}, content length: {len(content)}" ) - import json - + json_data = {} try: # Try parsing as JSON first to ensure valid JSON json_data = json.loads(content) logger.info("JSON parsed successfully, attempting model validation") - - # Validate from JSON (no prometheus_collector or file_watcher) + context = Context.model_validate(json_data) - - # Set excluded fields - context.file_watcher = self.file_watcher - context.prometheus_collector = self.prometheus_collector + username = context.username + if not User.exists(username): + raise ValueError(f"Attempt to load context {context.id} with invalid user {username}") + + matching_user = next((user for user in self.users if user.username == username), None) + if matching_user: + user = matching_user + else: + user = User(username=username, llm=self.llm) + user.initialize(prometheus_collector=self.prometheus_collector) + self.users.append(user) + context.user = user # Now set context on agents manually agent_types = [agent.agent_type for agent in context.agents] @@ -972,19 +950,33 @@ class WebServer: self.contexts[context_id] = context logger.info(f"Successfully loaded context {context_id}") + except ValidationError as e: + logger.error(e) + logger.error(traceback.format_exc()) + for error in e.errors(): + print(f"Field: {error['loc'][0]}, Error: {error['msg']}") except Exception as e: logger.error(f"Error validating context: {str(e)}") logger.error(traceback.format_exc()) + for key in json_data: + logger.info(f"{key} = {type(json_data[key])} {str(json_data[key])[:60] if json_data[key] else "None"}") + logger.info("*" * 50) + if len(self.users) == 0: + user = User(username=defines.default_username, llm=self.llm) + user.initialize(prometheus_collector=self.prometheus_collector) + self.users.append(user) # Fallback to creating a new context + user = self.users[0] self.contexts[context_id] = Context( id=context_id, - file_watcher=self.file_watcher, - prometheus_collector=self.prometheus_collector, + user=user, + rags=[ rag.model_copy() for rag in user.rags ], + tools=Tools.enabled_tools(Tools.tools) ) return self.contexts[context_id] - def create_context(self, context_id=None) -> Context: + def create_context(self, username: str, context_id=None) -> Context: """ Create a new context with a unique ID and default settings. Args: @@ -992,16 +984,43 @@ class WebServer: Returns: A Context object with the specified ID and default settings. """ - if not self.file_watcher: - raise Exception("File watcher not initialized") - if not context_id: - context_id = str(uuid4()) - logger.info(f"Creating new context with ID: {context_id}") - context = Context( - id=context_id, - file_watcher=self.file_watcher, - prometheus_collector=self.prometheus_collector, - ) + if not User.exists(username): + raise ValueError(f"{username} does not exist.") + + # If username + matching_user = next((user for user in self.users if user.username == username), None) + if matching_user: + user = matching_user + logger.info("Found matching user", user.model_dump(mode="json")) + else: + user = User(username=username, llm=self.llm) + user.initialize(prometheus_collector=self.prometheus_collector) + logger.info("Created new instance of user", user.model_dump(mode="json")) + self.users.append(user) + + logger.info(f"Creating context {context_id} with user", user.model_dump(mode='json')) + try: + if context_id: + context = Context( + id=context_id, + user=user, + rags=[ rag.model_copy() for rag in user.rags ], + tools=Tools.enabled_tools(Tools.tools) + ) + else: + context = Context( + user=user, + rags=[ rag.model_copy() for rag in user.rags ], + tools=Tools.enabled_tools(Tools.tools) + ) + except ValidationError as e: + logger.error(e) + logger.error(traceback.format_exc()) + for error in e.errors(): + print(f"Field: {error['loc'][0]}, Error: {error['msg']}") + exit(1) + + logger.info(f"New context created with ID: {context.id}") if os.path.exists(defines.resume_doc): context.user_resume = open(defines.resume_doc, "r").read() @@ -1010,8 +1029,6 @@ class WebServer: # context.add_agent(Resume(system_prompt = system_generate_resume)) # context.add_agent(JobDescription(system_prompt = system_job_description)) # context.add_agent(FactCheck(system_prompt = system_fact_check)) - context.tools = Tools.enabled_tools(Tools.tools) - context.rags = [ r.model_copy() for r in rags ] logger.info(f"{context.id} created and added to contexts.") self.contexts[context.id] = context @@ -1029,21 +1046,18 @@ class WebServer: if not context_id: logger.warning("No context ID provided. Creating a new context.") - return self.create_context() + return self.create_context(username=defines.default_username) if context_id in self.contexts: return self.contexts[context_id] logger.info(f"Context {context_id} is not yet loaded.") - return self.load_or_create_context(context_id) + return self.load_or_create_context(context_id=context_id) @REQUEST_TIME.time() async def generate_response( self, context: Context, agent: Agent, prompt: str, tunables: Tunables | None ) -> AsyncGenerator[Message, None]: - if not self.file_watcher: - raise Exception("File watcher not initialized") - agent_type = agent.get_agent_type() logger.info(f"generate_response: type - {agent_type}") # Merge tunables to take agent defaults and override with user supplied settings @@ -1090,10 +1104,12 @@ class WebServer: logger.info(f"Starting web server at http://{host}:{port}") uvicorn.run(self.app, host=host, port=port, log_config=None) except KeyboardInterrupt: - if self.observer: - self.observer.stop() - if self.observer: - self.observer.join() + for user in self.users: + if user.observer: + user.observer.stop() + for user in self.users: + if user.observer: + user.observer.join() # %% @@ -1101,8 +1117,6 @@ class WebServer: # Main function to run everything def main(): - global model - # Parse command-line arguments args = parse_args() @@ -1114,11 +1128,7 @@ def main(): warnings.filterwarnings("ignore", category=UserWarning, module="umap.*") llm = ollama.Client(host=args.ollama_server) # type: ignore - model = args.ollama_model - - web_server = WebServer(llm, model) - + web_server = WebServer(llm, args.ollama_model) web_server.run(host=args.web_host, port=args.web_port, use_reloader=False) - main() diff --git a/src/tests/test-context.py b/src/tests/test-context.py index 8486fa0..0ef6302 100644 --- a/src/tests/test-context.py +++ b/src/tests/test-context.py @@ -1,22 +1,7 @@ # From /opt/backstory run: # python -m src.tests.test-context -import os +from ..utils import Context -os.environ["TORCH_CPP_LOG_LEVEL"] = "ERROR" -import warnings - -import ollama - -from ..utils import rag as Rag, Context, defines - -import json - -llm = ollama.Client(host=defines.ollama_api_url) # type: ignore - -observer, file_watcher = Rag.start_file_watcher( - llm=llm, watch_directory=defines.doc_dir, recreate=False # Don't recreate if exists -) - -context = Context(file_watcher=file_watcher) +context = Context() json_data = context.model_dump(mode="json") context = Context.model_validate(json_data) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 82797ba..26cc7da 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -8,10 +8,11 @@ import importlib import json from . import defines +from .message import Message, Tunables, MessageMetaData +from .user import User from .context import Context from .conversation import Conversation -from .message import Message, Tunables, MessageMetaData -from .rag import ChromaDBFileWatcher, ChromaDBGetResponse, start_file_watcher +from .rag import ChromaDBFileWatcher, ChromaDBGetResponse, start_file_watcher, RagEntry from .setup_logging import setup_logging from .agents import class_registry, AnyAgent, Agent, __all__ as agents_all from .metrics import Metrics @@ -25,11 +26,13 @@ __all__ = [ "Context", "Conversation", "Metrics", + "RagEntry", "ChromaDBFileWatcher", 'ChromaDBGetResponse', "start_file_watcher", "check_serializable", "logger", + "User", ] __all__.extend(agents_all) # type: ignore diff --git a/src/utils/agents/base.py b/src/utils/agents/base.py index f1eb06e..127e121 100644 --- a/src/utils/agents/base.py +++ b/src/utils/agents/base.py @@ -157,7 +157,7 @@ class Agent(BaseModel, ABC): if message.tunables.enable_rag and message.prompt: # Gather RAG results, yielding each result # as it becomes available - for message in self.context.generate_rag_results(message): + for message in self.context.user.generate_rag_results(message): logger.info(f"RAG: {message.status} - {message.response}") if message.status == "error": yield message @@ -172,7 +172,7 @@ class Agent(BaseModel, ABC): message.preamble = {} if rag_context: - message.preamble["context"] = rag_context + message.preamble["context"] = f"The following is context information about {self.context.user.full_name}:\n{rag_context}" if message.tunables.enable_context and self.context.user_resume: message.preamble["resume"] = self.context.user_resume diff --git a/src/utils/agents/job_description.py b/src/utils/agents/job_description.py index a9c1687..98bae72 100644 --- a/src/utils/agents/job_description.py +++ b/src/utils/agents/job_description.py @@ -212,8 +212,7 @@ class JobDescription(Agent): def generate_resume_from_skill_assessments( self, - candidate_name, - candidate_contact_info, + candidate_info, skill_assessment_results, original_resume, ): @@ -273,19 +272,18 @@ class JobDescription(Agent): # Format contact info contact_info_str = "" - if candidate_contact_info: - contact_items = [] - for key, value in candidate_contact_info.items(): - if value: - contact_items.append(f"{key}: {value}") - contact_info_str = "\n".join(contact_items) + contact_items = [] + for key, value in candidate_info.get("contact_info", {}).items(): + if value: + contact_items.append(f"{key}: {value}") + contact_info_str = "\n".join(contact_items) # Build the system prompt system_prompt = f"""You are a professional resume writer with expertise in highlighting candidate strengths and experiences. Create a polished, concise, and ATS-friendly resume for the candidate based on the assessment data provided. ## CANDIDATE INFORMATION: -Name: {candidate_name} +Name: {candidate_info.full_name} {contact_info_str} ## SKILL ASSESSMENT RESULTS: @@ -381,14 +379,11 @@ Provide the resume in clean markdown format, ready for the candidate to use. """ # Extract candidate information - candidate_name = candidate_info.get("name", "") - candidate_contact = candidate_info.get("contact_info", {}) original_resume = candidate_info.get("original_resume", "") # Generate resume prompt system_prompt, prompt = self.generate_resume_from_skill_assessments( - candidate_name, - candidate_contact, + candidate_info, skill_assessment_results.values(), original_resume, ) @@ -874,18 +869,18 @@ IMPORTANT: Be factual and precise. If you cannot find strong evidence for this s yield message def retrieve_rag_content(self, skill: str) -> tuple[str, ChromaDBGetResponse]: - if self.context is None or self.context.file_watcher is None: - raise ValueError("self.context or self.context.file_watcher is None") + if self.context is None or self.context.user is None or self.context.user.file_watcher is None: + raise ValueError("self.context or self.context.user.file_watcher is None") try: rag_results = "" rag_metadata = ChromaDBGetResponse() - chroma_results = self.context.file_watcher.find_similar(query=skill, top_k=10, threshold=0.5) + chroma_results = self.context.user.file_watcher.find_similar(query=skill, top_k=10, threshold=0.5) if chroma_results: query_embedding = np.array(chroma_results["query_embedding"]).flatten() - umap_2d = self.context.file_watcher.umap_model_2d.transform([query_embedding])[0] - umap_3d = self.context.file_watcher.umap_model_3d.transform([query_embedding])[0] + umap_2d = self.context.user.file_watcher.umap_model_2d.transform([query_embedding])[0] + umap_3d = self.context.user.file_watcher.umap_model_3d.transform([query_embedding])[0] rag_metadata = ChromaDBGetResponse( query=skill, @@ -897,7 +892,7 @@ IMPORTANT: Be factual and precise. If you cannot find strong evidence for this s metadatas=chroma_results.get("metadatas", []), umap_embedding_2d=umap_2d.tolist(), umap_embedding_3d=umap_3d.tolist(), - size=self.context.file_watcher.collection.count() + size=self.context.user.file_watcher.collection.count() ) for index, metadata in enumerate(chroma_results["metadatas"]): @@ -1057,15 +1052,9 @@ Content: { content } "result": skill_assessment_results[skill_name], } - # Extract header from original resume candidate_info = { - "name": "James Ketrenos", - "contact_info": { - "email": "james@ketrenos.com", - "phone": "(503) 501 8281", - "location": "Beaverton, OR 97003", - }, - "original_resume": resume, + "full_name": self.context.user.full_name, + "contact_info": self.context.user.contact_info } # Stage 2: Generate tailored resume diff --git a/src/utils/context.py b/src/utils/context.py index 88fb3ce..e98f469 100644 --- a/src/utils/context.py +++ b/src/utils/context.py @@ -1,48 +1,85 @@ from __future__ import annotations from pydantic import BaseModel, Field, model_validator # type: ignore from uuid import uuid4 -from typing import List, Optional, Generator, ClassVar, Any +from typing import List, Optional, Generator, ClassVar, Any, TYPE_CHECKING from typing_extensions import Annotated, Union import numpy as np # type: ignore import logging from uuid import uuid4 -from prometheus_client import CollectorRegistry, Counter # type: ignore import traceback -from .message import Message, Tunables -from .rag import ChromaDBFileWatcher, ChromaDBGetResponse -from . import defines +from . rag import RagEntry from . import tools as Tools from .agents import AnyAgent +from . import User + +if TYPE_CHECKING: + from .user import User logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) - class Context(BaseModel): - model_config = {"arbitrary_types_allowed": True} # Allow ChromaDBFileWatcher - # Required fields - file_watcher: Optional[ChromaDBFileWatcher] = Field(default=None, exclude=True) - prometheus_collector: Optional[CollectorRegistry] = Field( - default=None, exclude=True - ) + class Config: + validate_by_name = True # Allow 'user' to be set via constructor + arbitrary_types_allowed = True # Allow ChromaDBFileWatcher - # Optional fields id: str = Field( default_factory=lambda: str(uuid4()), pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", ) + tools: List[dict] + rags: List[RagEntry] + username: str = "__invalid__" + + # "user" is not serialized and must be set after construction + Context__user: User = Field( + default_factory=lambda: User(username="__invalid__", llm=None, rags=[]), + alias="user", + exclude=True) + + # Optional fields user_resume: Optional[str] = None user_job_description: Optional[str] = None user_facts: Optional[str] = None - tools: List[dict] = Tools.enabled_tools(Tools.tools) - rags: List[ChromaDBGetResponse] = [] - message_history_length: int = 5 + # Class managed fields agents: List[Annotated[Union[*Agent.__subclasses__()], Field(discriminator="agent_type")]] = Field( # type: ignore default_factory=list ) + @model_validator(mode='after') + def set_user_and_username(self): + if self.Context__user.username != "__invalid__": + if self.username == "__invalid__": + logger.info(f"Binding context {self.id} to user {self.Context__user.username}") + self.username = self.Context__user.username + else: + raise ValueError("user can only be set once") + return self + + # Only allow dereference of 'user' if it has been set + @property + def user(self) -> User: + if self.Context__user.username == "__invalid__": + # After deserializing Context(), you must explicitly set the + # user: + # + # context = Context(...) + # context.user = + raise ValueError("Attempt to dereference default_factory constructed User") + return self.Context__user + + # Only allow setting of 'user' once + @user.setter + def user(self, new_user: User) -> User: + if self.Context__user.username != "__invalid__": + raise ValueError("user can only be set once") + logger.info(f"Binding context {self.id} to user {new_user.username}") + self.username = new_user.username + self.Context__user = new_user + return new_user + processing: bool = Field(default=False, exclude=True) # @model_validator(mode="before") @@ -64,74 +101,6 @@ class Context(BaseModel): # agent.set_context(self) return self - def generate_rag_results( - self, message: Message, top_k=defines.default_rag_top_k, threshold=defines.default_rag_threshold - ) -> Generator[Message, None, None]: - """ - Generate RAG results for the given query. - - Args: - query: The query string to generate RAG results for. - - Returns: - A list of dictionaries containing the RAG results. - """ - try: - message.status = "processing" - - entries: int = 0 - - if not self.file_watcher: - message.response = "No RAG context available." - message.status = "done" - yield message - return - - for rag in self.rags: - if not rag.enabled: - continue - message.response = f"Checking RAG context {rag.name}..." - yield message - chroma_results = self.file_watcher.find_similar( - query=message.prompt, top_k=top_k, threshold=threshold - ) - if chroma_results: - query_embedding = np.array(chroma_results["query_embedding"]).flatten() - - umap_2d = self.file_watcher.umap_model_2d.transform([query_embedding])[0] - umap_3d = self.file_watcher.umap_model_3d.transform([query_embedding])[0] - - rag_metadata = ChromaDBGetResponse( - query=message.prompt, - query_embedding=query_embedding.tolist(), - name=rag.name, - ids=chroma_results.get("ids", []), - embeddings=chroma_results.get("embeddings", []), - documents=chroma_results.get("documents", []), - metadatas=chroma_results.get("metadatas", []), - umap_embedding_2d=umap_2d.tolist(), - umap_embedding_3d=umap_3d.tolist(), - size=self.file_watcher.collection.count() - ) - - message.metadata.rag.append(rag_metadata) - message.response = f"Results from {rag.name} RAG: {len(chroma_results['documents'])} results." - yield message - - message.response = ( - f"RAG context gathered from results from {entries} documents." - ) - message.status = "done" - yield message - return - except Exception as e: - message.status = "error" - message.response = f"Error generating RAG results: {str(e)}" - logger.error(traceback.format_exc()) - logger.error(message.response) - yield message - return - def get_or_create_agent(self, agent_type: str, **kwargs) -> Agent: """ Get or create and append a new agent of the specified type, ensuring only one agent per type exists. @@ -200,7 +169,6 @@ class Context(BaseModel): summary += f"\nChat Name: {agent.name}\n" return summary - from .agents import Agent Context.model_rebuild() diff --git a/src/utils/defines.py b/src/utils/defines.py index 4374aba..7638aab 100644 --- a/src/utils/defines.py +++ b/src/utils/defines.py @@ -2,6 +2,14 @@ import os ollama_api_url = "http://ollama:11434" # Default Ollama local endpoint +user_dir = "/opt/backstory/users" +user_info_file = "info.json" # Relative to "{user_dir}/{user}" +default_username = "jketreno" +rag_content_dir = "rag-content" # Relative to "{user_dir}/{user}" +# Path to candidate full resume +resume_doc = "rag-content/resume/resume.md" # Relative to "{user_dir}/{user}/" (does not have to be in docs) +persist_directory = "db" # Relative to "{user_dir}/{user}" + # Model name License Notes # model = "deepseek-r1:7b" # MIT Tool calls don"t work # model = "gemma3:4b" # Gemma Requires newer ollama https://ai.google.dev/gemma/terms @@ -23,9 +31,6 @@ max_context = 2048 * 8 * 2 # Where to store session json files context_dir = "/opt/backstory/sessions" -# Path to candidate full resume -resume_doc = "/opt/backstory/docs/resume/resume.md" - # Location of frontend container's build output mapped into the container static_content = "/opt/backstory/frontend/deployed" @@ -34,12 +39,10 @@ logging_level = os.getenv("LOGGING_LEVEL", "INFO").upper() # RAG and Vector DB settings ## Where to read RAG content -persist_directory = os.getenv("PERSIST_DIR", "/opt/backstory/chromadb") -doc_dir = "/opt/backstory/docs/" chunk_buffer = 5 # Number of lines before and after chunk beyond the portion used in embedding (to return to callers) # Maximum number of entries for ChromaDB to find -default_rag_top_k = 30 +default_rag_top_k = 80 # Cosine Distance Equivalent Similarity Retrieval Characteristics # 0.2 - 0.3 0.85 - 0.90 Very strict, highly precise results only diff --git a/src/utils/rag.py b/src/utils/rag.py index 51077bc..4ba570f 100644 --- a/src/utils/rag.py +++ b/src/utils/rag.py @@ -37,6 +37,11 @@ __all__ = ["ChromaDBFileWatcher", "start_file_watcher", "ChromaDBGetResponse"] DEFAULT_CHUNK_SIZE = 750 DEFAULT_CHUNK_OVERLAP = 100 +class RagEntry(BaseModel): + name: str + description: str = "" + enabled: bool = True + class ChromaDBGetResponse(BaseModel): name: str = "" size: int = 0 @@ -56,7 +61,7 @@ class ChromaDBGetResponse(BaseModel): @field_validator("embeddings", "query_embedding", "umap_embedding_2d", "umap_embedding_3d") @classmethod def validate_embeddings(cls, value, field): - logging.info(f"Validating {field.field_name} with value: {type(value)} - {value}") + # logging.info(f"Validating {field.field_name} with value: {type(value)} - {value}") if value is None: return value if isinstance(value, np.ndarray): @@ -83,8 +88,8 @@ class ChromaDBFileWatcher(FileSystemEventHandler): llm, watch_directory, loop, - persist_directory=None, - collection_name="documents", + persist_directory, + collection_name, chunk_size=DEFAULT_CHUNK_SIZE, chunk_overlap=DEFAULT_CHUNK_OVERLAP, recreate=False, @@ -125,11 +130,13 @@ class ChromaDBFileWatcher(FileSystemEventHandler): self.processing_files = set() @property - def collection(self): + def collection(self) -> Collection: return self._collection @property - def umap_collection(self) -> ChromaDBGetResponse | None: + def umap_collection(self) -> ChromaDBGetResponse: + if not self._umap_collection: + raise ValueError("initialize_collection has not been called") return self._umap_collection @property @@ -342,7 +349,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler): # During initialization logging.info( - f"Updating 2D UMAP for {len(self._umap_collection['embeddings'])} vectors" + f"Updating 2D {self.collection_name} UMAP for {len(self._umap_collection['embeddings'])} vectors" ) vectors = np.array(self._umap_collection["embeddings"]) self._umap_model_2d = umap.UMAP( @@ -358,7 +365,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler): # ) # Should be 2 logging.info( - f"Updating 3D UMAP for {len(self._umap_collection['embeddings'])} vectors" + f"Updating 3D {self.collection_name} UMAP for {len(self._umap_collection['embeddings'])} vectors" ) self._umap_model_3d = umap.UMAP( n_components=3, @@ -374,6 +381,10 @@ class ChromaDBFileWatcher(FileSystemEventHandler): def _get_vector_collection(self, recreate=False) -> Collection: """Get or create a ChromaDB collection.""" + # Create the directory if it doesn't exist + if not os.path.exists(self.persist_directory): + os.makedirs(self.persist_directory) + # Initialize ChromaDB client chroma_client = chromadb.PersistentClient( # type: ignore path=self.persist_directory, @@ -402,10 +413,6 @@ class ChromaDBFileWatcher(FileSystemEventHandler): name=self.collection_name, metadata={"hnsw:space": "cosine"} ) - def create_chunks_from_documents(self, docs): - """Split documents into chunks using the text splitter.""" - return self.text_splitter.split_documents(docs) - def get_embedding(self, text: str) -> np.ndarray: """Generate and normalize an embedding for the given text.""" @@ -673,8 +680,8 @@ class ChromaDBFileWatcher(FileSystemEventHandler): def start_file_watcher( llm, watch_directory, - persist_directory=None, - collection_name="documents", + persist_directory, + collection_name, initialize=False, recreate=False, ): @@ -693,7 +700,7 @@ def start_file_watcher( file_watcher = ChromaDBFileWatcher( llm, - watch_directory, + watch_directory=watch_directory, loop=loop, persist_directory=persist_directory, collection_name=collection_name, @@ -719,31 +726,3 @@ def start_file_watcher( logging.info(f"Started watching directory: {watch_directory}") return observer, file_watcher - - -if __name__ == "__main__": - # When running directly, use absolute imports - import defines - - # Initialize Ollama client - llm = ollama.Client(host=defines.ollama_api_url) # type: ignore - - # Start the file watcher (with initialization) - observer, file_watcher = start_file_watcher( - llm, - defines.doc_dir, - recreate=True, # Start fresh - ) - - # Example query - query = "Can you describe James Ketrenos' work history?" - top_docs = file_watcher.find_similar(query, top_k=3) - logging.info(top_docs) - - try: - # Keep the main thread running - while True: - time.sleep(1) - except KeyboardInterrupt: - observer.stop() - observer.join() diff --git a/src/utils/user.py b/src/utils/user.py new file mode 100644 index 0000000..e0cc911 --- /dev/null +++ b/src/utils/user.py @@ -0,0 +1,216 @@ +from __future__ import annotations +from pydantic import BaseModel, Field, model_validator # type: ignore +from uuid import uuid4 +from typing import List, Optional, Generator, ClassVar, Any, Dict, TYPE_CHECKING + +from typing_extensions import Annotated, Union +import numpy as np # type: ignore +import logging +from uuid import uuid4 +from prometheus_client import CollectorRegistry, Counter # type: ignore +import traceback +import os +import json +import re +from pathlib import Path + + +from . rag import start_file_watcher, ChromaDBFileWatcher, ChromaDBGetResponse +from . import defines +from . import Message +#from . import Context + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +from .rag import RagEntry + +class User(BaseModel): + model_config = {"arbitrary_types_allowed": True} # Allow ChromaDBFileWatcher, etc + + username: str + llm: Any = Field(exclude=True) + rags: List[RagEntry] = Field(default_factory=list) + first_name: str = "" + last_name: str = "" + full_name: str = "" + contact_info : Dict[str, str] = {} + user_questions : List[str] = [] + + #context: Optional[List[Context]] = [] + # file_watcher : ChromaDBFileWatcher = set by initialize + # observer: Any = set by initialize + # prometheus_collector : CollectorRegistry = set by initialize + + # Internal instance members + User__observer: Optional[Any] = Field(default=None, exclude=True) + User__file_watcher: Optional[ChromaDBFileWatcher] = Field(default=None, exclude=True) + User__prometheus_collector: Optional[CollectorRegistry] = Field( + default=None, exclude=True + ) + + @classmethod + def exists(cls, username: str): + # Validate username format (only allow safe characters) + if not re.match(r'^[a-zA-Z0-9_-]+$', username): + return False # Invalid username characters + + # Check for minimum and maximum length + if not (3 <= len(username) <= 32): + return False # Invalid username length + + # Use Path for safe path handling and normalization + user_dir = Path(defines.user_dir) / username + user_info_path = user_dir / defines.user_info_file + + # Ensure the final path is actually within the intended parent directory + # to help prevent directory traversal attacks + try: + if not user_dir.resolve().is_relative_to(Path(defines.user_dir).resolve()): + return False # Path traversal attempt detected + except (ValueError, RuntimeError): # Potential exceptions from resolve() + return False + + # Check if file exists + return user_info_path.is_file() + + # Wrapper properties that map into file_watcher + @property + def umap_collection(self) -> ChromaDBGetResponse: + if not self.User__file_watcher: + raise ValueError("initialize() has not been called.") + return self.User__file_watcher.umap_collection + + # Fields managed by initialize() + User__initialized: bool = Field(default=False, exclude=True) + @property + def file_watcher(self) -> ChromaDBFileWatcher: + if not self.User__file_watcher: + raise ValueError("initialize() has not been called.") + return self.User__file_watcher + + @property + def prometheus_collector(self) -> CollectorRegistry: + if not self.User__prometheus_collector: + raise ValueError("initialize() has not been called.") + return self.User__prometheus_collector + + @property + def observer(self) -> Any: + if not self.User__observer: + raise ValueError("initialize() has not been called.") + return self.User__observer + + def generate_rag_results( + self, message: Message, top_k=defines.default_rag_top_k, threshold=defines.default_rag_threshold + ) -> Generator[Message, None, None]: + """ + Generate RAG results for the given query. + + Args: + query: The query string to generate RAG results for. + + Returns: + A list of dictionaries containing the RAG results. + """ + try: + message.status = "processing" + + entries: int = 0 + + for rag in self.rags: + if not rag.enabled: + continue + message.response = f"Checking RAG context {rag.name}..." + yield message + chroma_results = self.file_watcher.find_similar( + query=message.prompt, top_k=top_k, threshold=threshold + ) + if chroma_results: + query_embedding = np.array(chroma_results["query_embedding"]).flatten() + + umap_2d = self.file_watcher.umap_model_2d.transform([query_embedding])[0] + umap_3d = self.file_watcher.umap_model_3d.transform([query_embedding])[0] + + rag_metadata = ChromaDBGetResponse( + query=message.prompt, + query_embedding=query_embedding.tolist(), + name=rag.name, + ids=chroma_results.get("ids", []), + embeddings=chroma_results.get("embeddings", []), + documents=chroma_results.get("documents", []), + metadatas=chroma_results.get("metadatas", []), + umap_embedding_2d=umap_2d.tolist(), + umap_embedding_3d=umap_3d.tolist(), + size=self.file_watcher.collection.count() + ) + + message.metadata.rag.append(rag_metadata) + message.response = f"Results from {rag.name} RAG: {len(chroma_results['documents'])} results." + yield message + + message.response = ( + f"RAG context gathered from results from {entries} documents." + ) + message.status = "done" + yield message + return + except Exception as e: + message.status = "error" + message.response = f"Error generating RAG results: {str(e)}" + logger.error(traceback.format_exc()) + logger.error(message.response) + yield message + return + + def initialize(self, prometheus_collector): + if self.User__initialized: + # Initialization can only be attempted once; if there are multiple attempts, it means + # a subsystem is failing or there is a logic bug in the code. + # + # NOTE: It is intentional that self.User__initialize = True regardless of whether it + # succeeded. This prevents server loops on failure + raise ValueError("initialize can only be attempted once") + self.User__initialized = True + + user_dir = os.path.join(defines.user_dir, self.username) + user_info = os.path.join(user_dir, defines.user_info_file) + persist_directory=os.path.join(user_dir, defines.persist_directory) + watch_directory=os.path.join(user_dir, defines.rag_content_dir) + logger.info(f"User(username={self.username}, user_dir={user_dir} persist_directory={persist_directory}, watch_directory={watch_directory}") + + info = {} + # Always re-initialize the user's name and contact data from the info file in case it is changed + try: + with open(user_info, "r") as f: + info = json.loads(f.read()) + except Exception as e: + logger.error(f"Error processing {user_info}: {e}") + if info: + logger.error(f"info={info}") + + self.first_name = info.get("first_name", self.username) + self.last_name = info.get("first_name", "") + self.full_name = info.get("full_name", f"{self.first_name} {self.last_name}") + self.contact_info = info.get("contact_info", {}) + self.user_questions = info.get("questions", [ f"Tell me about {self.first_name}.", f"What are {self.first_name}'s professional strengths?"]) + + os.makedirs(persist_directory, exist_ok=True) + os.makedirs(watch_directory, exist_ok=True) + + self.User__prometheus_collector = prometheus_collector + self.User__observer, self.User__file_watcher = start_file_watcher( + llm=self.llm, + collection_name=self.username, + persist_directory=persist_directory, + watch_directory=watch_directory, + recreate=False, # Don't recreate if exists + ) + has_username_rag = any(item["name"] == self.username for item in self.rags) + if not has_username_rag: + self.rags.append(RagEntry( + name=self.username, + description=f"Expert data about {self.full_name}.", + )) + +User.model_rebuild() diff --git a/users-prod/.keep b/users-prod/.keep new file mode 100644 index 0000000..e69de29 diff --git a/users/.keep b/users/.keep new file mode 100644 index 0000000..e69de29 diff --git a/users/eliza/info.json b/users/eliza/info.json new file mode 100644 index 0000000..c1405a4 --- /dev/null +++ b/users/eliza/info.json @@ -0,0 +1,4 @@ +{ +"first_name": "Eliza", +"last_name": "Morgan" +} diff --git a/users/eliza/rag-content/jobs/plant-conrervation-specialist.md b/users/eliza/rag-content/jobs/plant-conrervation-specialist.md new file mode 100644 index 0000000..b3933b2 --- /dev/null +++ b/users/eliza/rag-content/jobs/plant-conrervation-specialist.md @@ -0,0 +1,63 @@ +# Plant Conservation Specialist + +**Organization:** Oregon Botanical Gardens +**Location:** Portland, Oregon +**Duration:** April 2017 - May 2020 + +## Position Overview +As Plant Conservation Specialist at the Oregon Botanical Gardens, I managed the institution's ex-situ conservation program for rare and endangered plant species native to the Pacific Northwest. This position bridged scientific research, hands-on horticulture, and public education. + +## Key Responsibilities + +### Ex-situ Conservation Program +- Coordinated conservation collections for 45 rare and endangered plant species +- Developed and maintained comprehensive database of accession records, phenology data, and propagation histories +- Established genetic management protocols to ensure maximum diversity in conservation collections +- Collaborated with Center for Plant Conservation on national rare plant conservation initiatives + +### Propagation & Cultivation +- Designed specialized growing environments for challenging species with specific habitat requirements +- Experimented with various propagation techniques including tissue culture, specialized seed treatments, and vegetative methods +- Maintained detailed documentation of successful and unsuccessful propagation attempts +- Achieved first-ever successful cultivation of three critically endangered Oregon wildflowers + +### Reintroduction Planning +- Collaborated with federal and state agencies on plant reintroduction strategies +- Conducted site assessments to evaluate habitat suitability for reintroductions +- Developed monitoring protocols to track survival and reproduction of reintroduced populations +- Prepared detailed reintroduction plans for 8 endangered species + +### Research Projects +- Designed and implemented germination studies for 15 rare species with unknown propagation requirements +- Conducted pollination biology investigations for several endangered plant species +- Collaborated with university researchers on seed viability and longevity studies +- Maintained comprehensive records of phenological patterns across multiple growing seasons + +### Education & Outreach +- Developed educational materials explaining the importance of plant conservation +- Led specialized tours focusing on rare plant conservation for visitors and donors +- Trained volunteers in proper care of sensitive plant collections +- Created interpretive signage for conservation garden displays + +## Notable Projects + +1. **Willamette Valley Prairie Species Recovery** + - Established seed bank of 25 declining prairie species + - Developed germination protocols that improved propagation success from 30% to 75% + - Produced over 5,000 plants for restoration projects throughout the region + +2. **Alpine Rare Plant Conservation Initiative** + - Created specialized growing facilities mimicking alpine conditions + - Successfully propagated 8 high-elevation rare species never before cultivated + - Documented critical temperature and moisture requirements for germination + +3. **Serpentine Soils Conservation Collection** + - Developed custom soil mixes replicating challenging serpentine conditions + - Maintained living collection of 12 rare serpentine endemic species + - Created public display educating visitors about specialized plant adaptations + +## Achievements +- Received "Conservation Innovation Award" from the American Public Gardens Association (2019) +- Developed propagation protocol for Kincaid's lupine that doubled germination success rates +- Established Oregon Botanical Gardens' first dedicated conservation nursery facility +- Created seed banking protocols adopted by three other botanical institutions \ No newline at end of file diff --git a/users/eliza/rag-content/jobs/research-assistant.md b/users/eliza/rag-content/jobs/research-assistant.md new file mode 100644 index 0000000..92d82af --- /dev/null +++ b/users/eliza/rag-content/jobs/research-assistant.md @@ -0,0 +1,75 @@ +# Research Assistant + +**Organization:** Institute for Applied Ecology +**Location:** Corvallis, Oregon +**Duration:** January 2015 - March 2017 + +## Position Overview +As Research Assistant at the Institute for Applied Ecology, I supported multiple research projects focused on native plant ecology and restoration techniques. This position provided foundational experience in applying scientific methods to practical conservation challenges. + +## Key Responsibilities + +### Field Surveys +- Conducted comprehensive botanical surveys in diverse ecosystems throughout western Oregon +- Documented population sizes, health metrics, and habitat conditions for threatened plant species +- Established long-term monitoring plots using standardized protocols +- Collected voucher specimens for herbarium collections following strict ethical guidelines +- Mapped plant populations using GPS and GIS technologies + +### Greenhouse Operations +- Assisted with propagation of native plants for restoration experiments and projects +- Maintained detailed records of seed treatments, germination rates, and growth parameters +- Implemented and monitored experimental growing conditions for research projects +- Managed irrigation systems and pest control for approximately 10,000 plants +- Prepared plant materials for outplanting at restoration sites + +### Data Collection & Analysis +- Collected vegetation data using quadrat, transect, and plot-based sampling methods +- Processed and organized large datasets for long-term monitoring studies +- Performed statistical analyses using R to assess restoration treatment effectiveness +- Created data visualization graphics for reports and publications +- Maintained research databases ensuring data quality and accessibility + +### Research Projects +- **Prairie Restoration Techniques:** + - Compared effectiveness of different site preparation methods on native plant establishment + - Monitored post-treatment recovery of native species diversity + - Documented invasive species response to various control techniques + +- **Rare Plant Demography:** + - Tracked population dynamics of three endangered Willamette Valley plant species + - Monitored individual plant survival, growth, and reproductive output + - Assessed impacts of management interventions on population trends + +- **Seed Viability Studies:** + - Tested germination requirements for 30+ native species + - Evaluated effects of smoke, scarification, and stratification on dormancy + - Documented optimal storage conditions for maintaining seed viability + +### Publication Support +- Co-authored three peer-reviewed publications on prairie restoration techniques +- Prepared figures, tables, and data appendices for manuscripts +- Conducted literature reviews on specialized ecological topics +- Assisted with manuscript revisions based on peer review feedback + +## Key Projects + +1. **Willamette Valley Wet Prairie Restoration** + - Implemented experimental plots testing 4 restoration techniques + - Collected 3 years of post-treatment vegetation data + - Documented successful establishment of 15 target native species + +2. **Endangered Butterfly Habitat Enhancement** + - Propagated host and nectar plants for Fender's blue butterfly habitat + - Monitored plant-insect interactions in restoration sites + - Assessed habitat quality improvements following restoration treatments + +3. **Native Seed Production Research** + - Tested cultivation methods for improving seed yields of 10 native species + - Documented pollination requirements for optimal seed production + - Developed harvest timing recommendations based on seed maturation patterns + +## Publications +- Johnson, T., **Morgan, E.**, et al. (2016). "Comparative effectiveness of site preparation techniques for prairie restoration." *Restoration Ecology*, 24(4), 472-481. +- Williams, R., **Morgan, E.**, & Smith, B. (2016). "Germination requirements of Willamette Valley wet prairie species." *Native Plants Journal*, 17(2), 99-112. +- **Morgan, E.**, Johnson, T., & Davis, A. (2017). "Long-term vegetation response to restoration treatments in degraded oak savanna." *Northwest Science*, 91(1), 27-39. \ No newline at end of file diff --git a/users/eliza/rag-content/jobs/restoration-botanist.md b/users/eliza/rag-content/jobs/restoration-botanist.md new file mode 100644 index 0000000..27a3b71 --- /dev/null +++ b/users/eliza/rag-content/jobs/restoration-botanist.md @@ -0,0 +1,55 @@ +# Senior Restoration Botanist + +**Organization:** Pacific Northwest Conservation Alliance +**Location:** Portland, Oregon +**Duration:** June 2020 - Present + +## Position Overview +As Senior Restoration Botanist at the Pacific Northwest Conservation Alliance, I lead complex restoration projects aimed at preserving endangered plant communities throughout the Cascade Range. This role combines technical botanical expertise with project management and leadership responsibilities. + +## Key Responsibilities + +### Project Leadership +- Design and implement comprehensive restoration plans for degraded ecosystems with emphasis on rare plant conservation +- Lead field operations across multiple concurrent restoration sites covering over 2,000 acres +- Establish measurable success criteria and monitoring protocols for all restoration projects +- Conduct regular site assessments to track progress and adapt management strategies + +### Native Plant Propagation +- Oversee native plant nursery operations producing 75,000+ plants annually +- Develop specialized propagation protocols for difficult-to-grow rare species +- Maintain detailed records of germination rates, growth metrics, and treatment effects +- Coordinate seed collection expeditions throughout diverse ecosystems of the Pacific Northwest + +### Team Management +- Supervise a core team of 5 field botanists and up to 12 seasonal restoration technicians +- Conduct staff training on plant identification, restoration techniques, and field safety +- Facilitate weekly team meetings and monthly progress reviews +- Mentor junior staff and provide professional development opportunities + +### Funding & Partnerships +- Secured $750,000 in grant funding for riparian habitat restoration projects +- Authored major sections of successful proposals to state and federal agencies +- Manage project budgets ranging from $50,000 to $250,000 +- Cultivate partnerships with government agencies, tribes, and conservation NGOs + +### Notable Projects +1. **Willamette Valley Prairie Restoration Initiative** + - Restored 350 acres of native prairie habitat + - Reintroduced 12 threatened plant species with 85% establishment success + - Developed innovative seeding techniques that increased native diversity by 40% + +2. **Mount Hood Meadow Rehabilitation** + - Led post-wildfire recovery efforts in alpine meadow ecosystems + - Implemented erosion control measures using native plant materials + - Achieved 90% reduction in invasive species cover within treatment areas + +3. **Columbia River Gorge Rare Plant Recovery** + - Established new populations of 5 federally listed plant species + - Developed habitat suitability models to identify optimal reintroduction sites + - Created monitoring protocols adopted by multiple conservation organizations + +## Achievements +- Received Excellence in Ecological Restoration Award from the Society for Ecological Restoration, Northwest Chapter (2023) +- Featured in Oregon Public Broadcasting documentary on native plant conservation (2022) +- Published 2 peer-reviewed articles on restoration techniques developed during project work \ No newline at end of file diff --git a/users/eliza/rag-content/resume/Eliza_Morgan_Resume.docx b/users/eliza/rag-content/resume/Eliza_Morgan_Resume.docx new file mode 100755 index 0000000000000000000000000000000000000000..f3e6832ce697c9b2fb3b30b49ecf2f11d478b13a GIT binary patch literal 38520 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 aV(X(rD|0?z1brJ-Vh|T#cyOhDJNg&30`aK; literal 0 HcmV?d00001 diff --git a/users/eliza/rag-content/resume/Eliza_Morgan_Resume.md b/users/eliza/rag-content/resume/Eliza_Morgan_Resume.md new file mode 100644 index 0000000..12326ac --- /dev/null +++ b/users/eliza/rag-content/resume/Eliza_Morgan_Resume.md @@ -0,0 +1,91 @@ +Eliza Morgan +Portland, Oregon | eliza.morgan@email.com | (555) 123-4567 +linkedin.com/in/elizamorgan | ORCID: 0000-0002-XXXX-XXXX + +**Professional Summary** + +Conservation botanist with over a decade of experience leading ecological restoration projects, advancing rare plant propagation methods, and managing native plant programs across the Pacific Northwest. Proven record of scientific innovation, collaborative project leadership, and effective stakeholder engagement. Passionate about preserving botanical diversity through applied research, restoration, and public education. + +**Professional Experience** + +**Senior Restoration Botanist** + +Pacific Northwest Conservation Alliance, Portland, OR | June 2020 – Present + +- Directed restoration efforts across 2,000+ acres of degraded habitat with a focus on endangered plant communities. +- Managed propagation and deployment of 75,000+ native plants annually. +- Supervised and mentored a cross-functional team of botanists and technicians. +- Secured $750,000+ in grant funding and led stakeholder engagement with tribal, governmental, and NGO partners. +- Developed seeding and reintroduction techniques increasing native species diversity by 40%. + +* Key Projects & Achievements: + +- Willamette Valley Prairie Restoration: Reintroduced 12 threatened species; 85% establishment success. + +- Mount Hood Meadow Rehabilitation: Reduced invasive cover by 90% post-wildfire. + +- Columbia River Gorge Rare Plant Recovery: Created new populations of 5 federally listed species. + +- Award: Excellence in Ecological Restoration, Society for Ecological Restoration (2023) + +- Media: Featured in OPB documentary on native plant conservation (2022) + +- Publications: 2 peer-reviewed articles on restoration innovations. + +**Plant Conservation Specialist** + +Oregon Botanical Gardens, Portland, OR | April 2017 – May 2020 + +- Led ex-situ conservation for 45 endangered Pacific Northwest plant species. +- Developed genetic management protocols and created Oregon Botanical’s first dedicated conservation nursery. +- Collaborated with the Center for Plant Conservation and state agencies on reintroduction planning. +- Authored seed banking protocols adopted by three other institutions. + +* Key Contributions: + +- Propagated 3 species never before cultivated; pioneered tissue culture and seed treatments. + +- Developed Kincaid’s lupine protocol doubling germination success. + +- Designed alpine and serpentine plant displays for public education. + +- Award: Conservation Innovation Award, American Public Gardens Association (2019) + +**Research Assistant** + +Institute for Applied Ecology, Corvallis, OR | January 2015 – March 2017 + +- Conducted botanical field surveys, greenhouse propagation, and data analysis for native plant research projects. +- Co-authored 3 peer-reviewed studies on prairie restoration, seed viability, and plant demography. +- Supported endangered species monitoring and habitat restoration for the Fender’s blue butterfly. + +* Highlighted Projects: + +- Wet Prairie Restoration: Established 15 native species across experimental plots. + +- Seed Viability Studies: Tested dormancy-breaking treatments for 30+ species. + +- Publications: + +- Morgan, E. et al. (2017). \*Northwest Science\* + +- Morgan, E. et al. (2016). \*Restoration Ecology\*, \*Native Plants Journal\* + +**Education** + +B.S. in Botany | Oregon State University, Corvallis, OR | Graduated: 2014 + +**Skills & Expertise** + +- Native plant propagation & nursery management +- Ecological restoration & reintroduction planning +- Grant writing & budget management +- GIS & GPS mapping | R statistical analysis +- Team leadership & staff mentoring +- Science communication & outreach + +**Professional Affiliations** + +- Society for Ecological Restoration (SER) +- American Public Gardens Association (APGA) +- Center for Plant Conservation (CPC) \ No newline at end of file