Multi-user almost working

This commit is contained in:
James Ketr 2025-05-17 17:27:46 -07:00
parent 4bbaa15e09
commit 72219100ee
24 changed files with 870 additions and 398 deletions

View File

@ -21,11 +21,10 @@ services:
ports: ports:
- 8912:8911 # FastAPI React server - 8912:8911 # FastAPI React server
volumes: 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 - ./sessions:/opt/backstory/sessions:rw # Persist sessions
- ./chromadb:/opt/backstory/chromadb:rw # Persist ChromaDB
- ./dev-keys:/opt/backstory/keys:ro # Developer keys - ./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 - ./src:/opt/backstory/src:rw # Live mount server src
cap_add: # used for running ze-monitor within container cap_add: # used for running ze-monitor within container
- CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks - CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks
@ -58,6 +57,7 @@ services:
- ./chromadb-prod:/opt/backstory/chromadb:rw # Persist ChromaDB - ./chromadb-prod:/opt/backstory/chromadb:rw # Persist ChromaDB
- ./sessions-prod:/opt/backstory/sessions:rw # Persist sessions - ./sessions-prod:/opt/backstory/sessions:rw # Persist sessions
- ./docs-prod:/opt/backstory/docs:rw # Live mount of RAG content - ./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 - ./frontend/deployed:/opt/backstory/frontend/deployed:ro # Live mount built frontend
cap_add: # used for running ze-monitor within container cap_add: # used for running ze-monitor within container
- CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks - CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks

View File

@ -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]; return [loadingTab];
} else { } else {
return [ return [
@ -183,6 +183,9 @@ const Main = (props: MainProps) => {
}; };
useEffect(() => { 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 pathParts = window.location.pathname.split('/').filter(Boolean);
const currentPath = pathParts.length < 2 ? '' : pathParts[0]; const currentPath = pathParts.length < 2 ? '' : pathParts[0];
let currentSubRoute = pathParts.length > 2 ? pathParts.slice(1, -1).join('/') : ''; let currentSubRoute = pathParts.length > 2 ? pathParts.slice(1, -1).join('/') : '';
@ -197,7 +200,7 @@ const Main = (props: MainProps) => {
setTab(tabs[tabIndex]); setTab(tabs[tabIndex]);
setSubRoute(currentSubRoute); setSubRoute(currentSubRoute);
console.log(`Initial load set to tab ${tabs[tabIndex].path} subRoute: ${currentSubRoute}`); console.log(`Initial load set to tab ${tabs[tabIndex].path} subRoute: ${currentSubRoute}`);
}, [tabs]); }, [tabs, sessionId]);
useEffect(() => { useEffect(() => {
if (tab === undefined || sessionId === undefined) { if (tab === undefined || sessionId === undefined) {

View File

@ -3,8 +3,12 @@ import { useNavigate, useLocation } from "react-router-dom";
import { connectionBase } from '../Global'; import { connectionBase } from '../Global';
import { SetSnackType } from '../Components/Snack'; import { SetSnackType } from '../Components/Snack';
const getSessionId = async () => { const getSessionId = async (userId?: string) => {
const response = await fetch(connectionBase + `/api/context`, { const endpoint = userId
? `/api/context/u/${encodeURIComponent(userId)}`
: `/api/context`;
const response = await fetch(connectionBase + endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -34,9 +38,27 @@ const SessionWrapper = ({ setSnack, children }: SessionWrapperProps) => {
const [retry, setRetry] = useState<number>(0); const [retry, setRetry] = useState<number>(0);
useEffect(() => { useEffect(() => {
console.log(`SessionWrapper: ${location.pathname}`);
const ensureSessionId = async () => { const ensureSessionId = async () => {
const parts = location.pathname.split("/").filter(Boolean); 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; 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]); const hasSession = parts.length !== 0 && pattern.test(parts[parts.length - 1]);
if (!hasSession) { if (!hasSession) {
@ -53,18 +75,24 @@ const SessionWrapper = ({ setSnack, children }: SessionWrapperProps) => {
if (!fetchingRef.current) { if (!fetchingRef.current) {
fetchingRef.current = true; fetchingRef.current = true;
ensureSessionId().catch((e) => { ensureSessionId()
console.error(e); .catch((e) => {
setSnack("Backstory is temporarily unavailable. Retrying in 5 seconds.", "warning"); console.error(e);
setTimeout(() => { setSnack("Backstory is temporarily unavailable. Retrying in 5 seconds.", "warning");
fetchingRef.current = false; setTimeout(() => {
setRetry(retry => retry + 1) fetchingRef.current = false;
}, 5000); setRetry(retry => retry + 1);
}); }, 5000);
})
.finally(() => {
if (fetchingRef.current) {
fetchingRef.current = false;
}
});
} }
}, [location.pathname, navigate, setSnack, sessionId, retry]); }, [location.pathname, navigate, setSnack, sessionId, retry]);
return <>{children}</>; return <>{children}</>;
}; };
export { SessionWrapper }; export { SessionWrapper };

View File

@ -117,8 +117,6 @@ const MessageMeta = (props: MessageMetaProps) => {
} = props.metadata || {}; } = props.metadata || {};
const message: any = props.messageProps.message; const message: any = props.messageProps.message;
console.log(tools, rag);
let llm_submission: string = "<|system|>\n" let llm_submission: string = "<|system|>\n"
llm_submission += message.system_prompt + "\n\n" llm_submission += message.system_prompt + "\n\n"
llm_submission += message.context_prompt llm_submission += message.context_prompt

View File

@ -163,7 +163,7 @@ const colorMap: Record<string, string> = {
resume: '#4A7A7D', // Dusty Teal — secondary theme color resume: '#4A7A7D', // Dusty Teal — secondary theme color
projects: '#1A2536', // Midnight Blue — rich and deep projects: '#1A2536', // Midnight Blue — rich and deep
news: '#D3CDBF', // Warm Gray — soft and neutral news: '#D3CDBF', // Warm Gray — soft and neutral
'performance-reviews': '#FFD0D0', // Light red 'performance-reviews': '#8FD0D0', // Light red
'jobs': '#F3aD8F', // Warm Gray — soft and neutral 'jobs': '#F3aD8F', // Warm Gray — soft and neutral
}; };
@ -214,7 +214,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
const plotContainerRect = plotContainer.getBoundingClientRect(); const plotContainerRect = plotContainer.getBoundingClientRect();
svgContainer.style.width = `${plotContainerRect.width}px`; svgContainer.style.width = `${plotContainerRect.width}px`;
svgContainer.style.height = `${plotContainerRect.height}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 }); setPlotDimensions({ width: plotContainerRect.width, height: plotContainerRect.height });
} }
} }
@ -347,6 +347,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
size: filtered_sizes, size: filtered_sizes,
symbol: 'circle', symbol: 'circle',
color: filtered_colors, color: filtered_colors,
opacity: 1
}, },
text: filtered.ids, text: filtered.ids,
customdata: filtered.metadatas, customdata: filtered.metadatas,
@ -361,6 +362,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
size: query_sizes, size: query_sizes,
symbol: 'circle', symbol: 'circle',
color: query_colors, color: query_colors,
opacity: 1
}, },
text: query.ids, text: query.ids,
customdata: query.metadatas, customdata: query.metadatas,
@ -473,13 +475,6 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
<Box className="VectorVisualizer" <Box className="VectorVisualizer"
ref={boxRef} ref={boxRef}
sx={{ sx={{
display: 'flex',
position: 'relative',
flexDirection: 'column',
flexGrow: 1,
m: 0,
p: 0,
border: "none",
...sx ...sx
}}> }}>
<Box sx={{ p: 0, m: 0, gap: 0 }}> <Box sx={{ p: 0, m: 0, gap: 0 }}>
@ -513,7 +508,7 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
control={<Switch checked={!view2D} />} onChange={() => setView2D(!view2D)} label="3D" /> control={<Switch checked={!view2D} />} onChange={() => setView2D(!view2D)} label="3D" />
<Plot <Plot
ref={plotlyRef} ref={plotlyRef}
onClick={(event: any) => { console.log("click"); onNodeSelected(event.points[0].customdata); }} onClick={(event: any) => { onNodeSelected(event.points[0].customdata); }}
data={plotData} data={plotData}
useResizeHandler={true} useResizeHandler={true}
config={config} config={config}

View File

@ -19,7 +19,6 @@ import { BackstoryPageProps } from '../Components/BackstoryTab';
interface ServerTunables { interface ServerTunables {
system_prompt: string, system_prompt: string,
message_history_length: number,
tools: Tool[], tools: Tool[],
rags: Tool[] rags: Tool[]
}; };
@ -125,38 +124,7 @@ const ControlsPage = (props: BackstoryPageProps) => {
}, [systemPrompt, sessionId, setSnack, serverTunables]); }, [systemPrompt, sessionId, setSnack, serverTunables]);
useEffect(() => { const reset = async (types: ("rags" | "tools" | "history" | "system_prompt")[], message: string = "Update successful.") => {
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.") => {
try { try {
const response = await fetch(connectionBase + `/api/reset/${sessionId}`, { const response = await fetch(connectionBase + `/api/reset/${sessionId}`, {
method: 'PUT', method: 'PUT',
@ -308,7 +276,6 @@ const ControlsPage = (props: BackstoryPageProps) => {
// console.log("Server tunables: ", data); // console.log("Server tunables: ", data);
setServerTunables(data); setServerTunables(data);
setSystemPrompt(data["system_prompt"]); setSystemPrompt(data["system_prompt"]);
setMessageHistoryLength(data["message_history_length"]);
setTools(data["tools"]); setTools(data["tools"]);
setRags(data["rags"]); setRags(data["rags"]);
} catch (error) { } catch (error) {
@ -453,7 +420,7 @@ const ControlsPage = (props: BackstoryPageProps) => {
</Accordion> </Accordion>
{/* <Button startIcon={<ResetIcon />} onClick={() => { reset(["history"], "History cleared."); }}>Delete Backstory History</Button> {/* <Button startIcon={<ResetIcon />} onClick={() => { reset(["history"], "History cleared."); }}>Delete Backstory History</Button>
<Button onClick={() => { reset(["rags", "tools", "system_prompt", "message_history_length"], "Default settings restored.") }}>Reset system prompt, tunables, and RAG to defaults</Button> */} <Button onClick={() => { reset(["rags", "tools", "system_prompt"], "Default settings restored.") }}>Reset system prompt, tunables, and RAG to defaults</Button> */}
</div>); </div>);
} }

View File

@ -1,4 +1,4 @@
import React, { forwardRef } from 'react'; import React, { forwardRef, useEffect, useState } from 'react';
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from '@mui/material/useMediaQuery';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
@ -9,45 +9,85 @@ import { Conversation, ConversationHandle } from '../Components/Conversation';
import { ChatQuery } from '../Components/ChatQuery'; import { ChatQuery } from '../Components/ChatQuery';
import { MessageList } from '../Components/Message'; 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<string, string>;
questions: string[];
};
const HomePage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => { const HomePage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => {
const { sessionId, setSnack, submitQuery } = props; const { sessionId, setSnack, submitQuery } = props;
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [preamble, setPreamble] = useState<MessageList>([]);
const [questions, setQuestions] = useState<React.ReactElement[]>([]);
const [user, setUser] = useState<UserData | undefined>(undefined)
if (sessionId === undefined) { useEffect(() => {
return <></>; if (user === undefined) {
} return;
}
const backstoryPreamble: MessageList = [ setPreamble([{
{
role: 'content', role: 'content',
title: 'Welcome to Backstory', title: 'Welcome to Backstory',
disableCopy: true, disableCopy: true,
content: ` content: `
Backstory is a RAG enabled expert system with access to real-time data running self-hosted 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). (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([
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}> <Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}>
<ChatQuery query={{ prompt: "What is James Ketrenos' work history?", tunables: { enable_tools: false } }} submitQuery={submitQuery} /> {user.questions.map((q: string, i: number) =>
<ChatQuery query={{ prompt: "Provide an exhaustive list of programming languages James has used.", tunables: { enable_tools: false } }} submitQuery={submitQuery} /> <ChatQuery key={i} query={{ prompt: q, tunables: { enable_tools: false } }} submitQuery={submitQuery} />
<ChatQuery query={{ prompt: "What are James' professional strengths?", tunables: { enable_tools: false } }} submitQuery={submitQuery} /> )}
<ChatQuery query={{ prompt: "What are today's headlines on CNBC.com?", tunables: { enable_tools: true, enable_rag: false, enable_context: false } }} submitQuery={submitQuery} />
</Box>, </Box>,
<Box sx={{ p: 1 }}> <Box sx={{ p: 1 }}>
<MuiMarkdown> <MuiMarkdown>
As with all LLM interactions, the results may not be 100% accurate. If you have questions about my career, {`As with all LLM interactions, the results may not be 100% accurate. Please contact **${user.full_name}** if you have any questions.`}
I'd love to hear from you. You can send me an email at **james_backstory@ketrenos.com**.
</MuiMarkdown> </MuiMarkdown>
</Box> </Box>]);
]; }, [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 <Conversation return <Conversation
sx={{ sx={{
@ -62,8 +102,8 @@ What would you like to know about James?
resetLabel: "chat", resetLabel: "chat",
sessionId, sessionId,
setSnack, setSnack,
preamble: backstoryPreamble, preamble: preamble,
defaultPrompts: backstoryQuestions, defaultPrompts: questions,
submitQuery, submitQuery,
}} }}
/>; />;

View File

@ -21,10 +21,8 @@ import subprocess
import re import re
import math import math
import warnings import warnings
from typing import Any # from typing import Any
from datetime import datetime
import inspect import inspect
from uuid import uuid4
import time import time
import traceback import traceback
@ -63,7 +61,7 @@ from prometheus_client import CollectorRegistry, Counter # type: ignore
from utils import ( from utils import (
rag as Rag, rag as Rag,
ChromaDBGetResponse, RagEntry,
tools as Tools, tools as Tools,
Context, Context,
Conversation, Conversation,
@ -72,26 +70,15 @@ from utils import (
Metrics, Metrics,
Tunables, Tunables,
defines, defines,
User,
check_serializable, check_serializable,
logger, 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): class Query(BaseModel):
prompt: str prompt: str
tunables: Tunables = Field(default_factory=Tunables) 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") REQUEST_TIME = Summary("request_processing_seconds", "Time spent processing request")
@ -182,12 +169,6 @@ WEB_HOST = "0.0.0.0"
WEB_PORT = 8911 WEB_PORT = 8911
DEFAULT_HISTORY_LENGTH = 5 DEFAULT_HISTORY_LENGTH = 5
# %%
# Globals
model = None
web_server = None
# %% # %%
# Cmd line overrides # Cmd line overrides
@ -246,19 +227,11 @@ def is_valid_uuid(value: str) -> bool:
class WebServer: class WebServer:
@asynccontextmanager @asynccontextmanager
async def lifespan(self, app: FastAPI): 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 yield
if self.observer: for user in self.users:
self.observer.stop() if user.observer:
self.observer.join() user.observer.stop()
user.observer.join()
logger.info("File watcher stopped") logger.info("File watcher stopped")
def __init__(self, llm, model=MODEL_NAME): def __init__(self, llm, model=MODEL_NAME):
@ -276,13 +249,12 @@ class WebServer:
# Expose the /metrics endpoint # Expose the /metrics endpoint
self.instrumentator.expose(self.app, endpoint="/metrics") self.instrumentator.expose(self.app, endpoint="/metrics")
self.contexts = {}
self.llm = llm self.llm = llm
self.model = model self.model = model
self.processing = False 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( self.ssl_enabled = os.path.exists(defines.key_path) and os.path.exists(
defines.cert_path defines.cert_path
@ -308,7 +280,7 @@ class WebServer:
def setup_routes(self): def setup_routes(self):
@self.app.get("/") @self.app.get("/")
async def root(): async def root():
context = self.create_context() context = self.create_context(username=defines.default_username)
logger.info(f"Redirecting non-context to {context.id}") logger.info(f"Redirecting non-context to {context.id}")
return RedirectResponse(url=f"/{context.id}", status_code=307) return RedirectResponse(url=f"/{context.id}", status_code=307)
# return JSONResponse({"redirect": f"/{context.id}"}) # return JSONResponse({"redirect": f"/{context.id}"})
@ -317,15 +289,14 @@ class WebServer:
async def get_umap(doc_id: str, context_id: str, request: Request): async def get_umap(doc_id: str, context_id: str, request: Request):
logger.info(f"{request.method} {request.url.path}") logger.info(f"{request.method} {request.url.path}")
try: try:
if not self.file_watcher:
raise Exception("File watcher not initialized")
context = self.upsert_context(context_id) context = self.upsert_context(context_id)
if not context: if not context:
return JSONResponse( return JSONResponse(
{"error": f"Invalid context: {context_id}"}, status_code=400 {"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: if not collection:
return JSONResponse( return JSONResponse(
{"error": "No UMAP collection found"}, status_code=404 {"error": "No UMAP collection found"}, status_code=404
@ -336,7 +307,7 @@ class WebServer:
for index, id in enumerate(collection.get("ids", [])): for index, id in enumerate(collection.get("ids", [])):
if id == doc_id: if id == doc_id:
metadata = collection.get("metadatas", [])[index].copy() 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(content)
return JSONResponse(f"Document id {doc_id} not found.", 404) 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): async def put_umap(context_id: str, request: Request):
logger.info(f"{request.method} {request.url.path}") logger.info(f"{request.method} {request.url.path}")
try: try:
if not self.file_watcher:
raise Exception("File watcher not initialized")
context = self.upsert_context(context_id) context = self.upsert_context(context_id)
if not context: if not context:
return JSONResponse( return JSONResponse(
{"error": f"Invalid context: {context_id}"}, status_code=400 {"error": f"Invalid context: {context_id}"}, status_code=400
) )
user = context.user
data = await request.json() data = await request.json()
dimensions = data.get("dimensions", 2) dimensions = data.get("dimensions", 2)
collection = self.file_watcher.umap_collection collection = user.file_watcher.umap_collection
if not collection: if not collection:
return JSONResponse( return JSONResponse(
{"error": "No UMAP collection found"}, status_code=404 {"error": "No UMAP collection found"}, status_code=404
) )
if dimensions == 2: if dimensions == 2:
logger.info("Returning 2D UMAP") logger.info("Returning 2D UMAP")
umap_embedding = self.file_watcher.umap_embedding_2d umap_embedding = user.file_watcher.umap_embedding_2d
else: else:
logger.info("Returning 3D UMAP") 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: if len(umap_embedding) == 0:
return JSONResponse( return JSONResponse(
@ -382,26 +349,21 @@ class WebServer:
"metadatas": collection.get("metadatas", []), "metadatas": collection.get("metadatas", []),
"documents": collection.get("documents", []), "documents": collection.get("documents", []),
"embeddings": umap_embedding.tolist(), "embeddings": umap_embedding.tolist(),
"size": self.file_watcher.collection.count() "size": user.file_watcher.collection.count()
} }
return JSONResponse(result) return JSONResponse(result)
except Exception as e: except Exception as e:
logger.error(f"put_umap error: {str(e)}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
logger.error(f"put_umap error: {str(e)}")
return JSONResponse({"error": str(e)}, 500) return JSONResponse({"error": str(e)}, 500)
@self.app.put("/api/similarity/{context_id}") @self.app.put("/api/similarity/{context_id}")
async def put_similarity(context_id: str, request: Request): async def put_similarity(context_id: str, request: Request):
logger.info(f"{request.method} {request.url.path}") logger.info(f"{request.method} {request.url.path}")
if not self.file_watcher: context = self.upsert_context(context_id)
raise Exception("File watcher not initialized") user = context.user
if not is_valid_uuid(context_id):
logger.warning(f"Invalid context_id: {context_id}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400)
try: try:
data = await request.json() data = await request.json()
query = data.get("query", "") query = data.get("query", "")
@ -417,7 +379,7 @@ class WebServer:
status_code=400, status_code=400,
) )
try: try:
chroma_results = self.file_watcher.find_similar( chroma_results = user.file_watcher.find_similar(
query=query, top_k=results, threshold=threshold query=query, top_k=results, threshold=threshold
) )
if not chroma_results: if not chroma_results:
@ -428,14 +390,14 @@ class WebServer:
).flatten() # Ensure correct shape ).flatten() # Ensure correct shape
logger.info(f"Chroma embedding shape: {chroma_embedding.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 0
].tolist() ].tolist()
logger.info( logger.info(
f"UMAP 2D output: {umap_2d}, length: {len(umap_2d)}" f"UMAP 2D output: {umap_2d}, length: {len(umap_2d)}"
) # Debug output ) # 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 0
].tolist() ].tolist()
logger.info( logger.info(
@ -449,7 +411,7 @@ class WebServer:
"query": query, "query": query,
"umap_embedding_2d": umap_2d, "umap_embedding_2d": umap_2d,
"umap_embedding_3d": umap_3d, "umap_embedding_3d": umap_3d,
"size": self.file_watcher.collection.count() "size": user.file_watcher.collection.count()
}) })
except Exception as e: except Exception as e:
@ -478,7 +440,7 @@ class WebServer:
logger.info(f"Resetting {reset_operation}") logger.info(f"Resetting {reset_operation}")
case "rags": case "rags":
logger.info(f"Resetting {reset_operation}") 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 ] response["rags"] = [ r.model_dump(mode="json") for r in context.rags ]
case "tools": case "tools":
logger.info(f"Resetting {reset_operation}") logger.info(f"Resetting {reset_operation}")
@ -511,10 +473,6 @@ class WebServer:
tmp.conversation.reset() tmp.conversation.reset()
response["history"] = [] response["history"] = []
response["context_used"] = agent.context_tokens 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: if not response:
return JSONResponse( return JSONResponse(
@ -548,6 +506,7 @@ class WebServer:
for k in data.keys(): for k in data.keys():
match k: match k:
case "tools": case "tools":
from typing import Any
# { "tools": [{ "tool": tool?.name, "enabled": tool.enabled }] } # { "tools": [{ "tool": tool?.name, "enabled": tool.enabled }] }
tools: list[dict[str, Any]] = data[k] tools: list[dict[str, Any]] = data[k]
if not tools: if not tools:
@ -575,6 +534,7 @@ class WebServer:
) )
case "rags": case "rags":
from typing import Any
# { "rags": [{ "tool": tool?.name, "enabled": tool.enabled }] } # { "rags": [{ "tool": tool?.name, "enabled": tool.enabled }] }
rag_configs: list[dict[str, Any]] = data[k] rag_configs: list[dict[str, Any]] = data[k]
if not rag_configs: if not rag_configs:
@ -603,11 +563,6 @@ class WebServer:
agent.system_prompt = system_prompt agent.system_prompt = system_prompt
self.save_context(context_id) self.save_context(context_id)
return JSONResponse({"system_prompt": system_prompt}) 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 _: case _:
return JSONResponse( return JSONResponse(
{"error": f"Unrecognized tunable {k}"}, status_code=404 {"error": f"Unrecognized tunable {k}"}, status_code=404
@ -616,6 +571,20 @@ class WebServer:
logger.error(f"Error in put_tunables: {e}") logger.error(f"Error in put_tunables: {e}")
return JSONResponse({"error": str(e)}, status_code=500) 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}") @self.app.get("/api/tunables/{context_id}")
async def get_tunables(context_id: str, request: Request): async def get_tunables(context_id: str, request: Request):
logger.info(f"{request.method} {request.url.path}") logger.info(f"{request.method} {request.url.path}")
@ -630,7 +599,6 @@ class WebServer:
return JSONResponse( return JSONResponse(
{ {
"system_prompt": agent.system_prompt, "system_prompt": agent.system_prompt,
"message_history_length": context.message_history_length,
"rags": [ r.model_dump(mode="json") for r in context.rags ], "rags": [ r.model_dump(mode="json") for r in context.rags ],
"tools": [ "tools": [
{ {
@ -753,8 +721,8 @@ class WebServer:
await asyncio.sleep(0) await asyncio.sleep(0)
except Exception as e: except Exception as e:
context.processing = False context.processing = False
logger.error(f"Error in generate_response: {e}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
logger.error(f"Error in generate_response: {e}")
yield json.dumps({"status": "error", "response": str(e)}) + "\n" yield json.dumps({"status": "error", "response": str(e)}) + "\n"
finally: finally:
# Save context on completion or error # Save context on completion or error
@ -775,16 +743,25 @@ class WebServer:
logger.error(f"Error in post_chat_endpoint: {e}") logger.error(f"Error in post_chat_endpoint: {e}")
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
@self.app.post("/api/context") @self.app.post("/api/context/u/{username}")
async def create_context(): async def create_user_context(username: str, request: Request):
logger.info(f"{request.method} {request.url.path}")
try: try:
context = self.create_context() if not User.exists(username):
logger.info(f"Generated new agent as {context.id}") 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}) return JSONResponse({"id": context.id})
except Exception as e: 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()) 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}") @self.app.get("/api/history/{context_id}/{agent_type}")
async def get_history(context_id: str, agent_type: str, request: Request): async def get_history(context_id: str, agent_type: str, request: Request):
@ -802,10 +779,8 @@ class WebServer:
) )
return agent.conversation return agent.conversation
except Exception as e: except Exception as e:
logger.error(f"get_history error: {str(e)}")
import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
logger.error(f"get_history error: {str(e)}")
return JSONResponse({"error": str(e)}, status_code=404) return JSONResponse({"error": str(e)}, status_code=404)
@self.app.get("/api/tools/{context_id}") @self.app.get("/api/tools/{context_id}")
@ -922,7 +897,7 @@ class WebServer:
return context_id 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. Load a context from a file in the context directory or create a new one if it doesn't exist.
Args: Args:
@ -930,15 +905,12 @@ class WebServer:
Returns: Returns:
A Context object with the specified ID and default settings. 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) file_path = os.path.join(defines.context_dir, context_id)
# Check if the file exists # Check if the file exists
if not os.path.exists(file_path): if not os.path.exists(file_path):
logger.info(f"Context file {file_path} not found. Creating new context.") 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: else:
# Read and deserialize the data # Read and deserialize the data
with open(file_path, "r") as f: with open(file_path, "r") as f:
@ -946,19 +918,25 @@ class WebServer:
logger.info( logger.info(
f"Loading context from {file_path}, content length: {len(content)}" f"Loading context from {file_path}, content length: {len(content)}"
) )
import json json_data = {}
try: try:
# Try parsing as JSON first to ensure valid JSON # Try parsing as JSON first to ensure valid JSON
json_data = json.loads(content) json_data = json.loads(content)
logger.info("JSON parsed successfully, attempting model validation") logger.info("JSON parsed successfully, attempting model validation")
# Validate from JSON (no prometheus_collector or file_watcher)
context = Context.model_validate(json_data) context = Context.model_validate(json_data)
username = context.username
# Set excluded fields if not User.exists(username):
context.file_watcher = self.file_watcher raise ValueError(f"Attempt to load context {context.id} with invalid user {username}")
context.prometheus_collector = self.prometheus_collector
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 # Now set context on agents manually
agent_types = [agent.agent_type for agent in context.agents] agent_types = [agent.agent_type for agent in context.agents]
@ -972,19 +950,33 @@ class WebServer:
self.contexts[context_id] = context self.contexts[context_id] = context
logger.info(f"Successfully loaded context {context_id}") 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: except Exception as e:
logger.error(f"Error validating context: {str(e)}") logger.error(f"Error validating context: {str(e)}")
logger.error(traceback.format_exc()) 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 # Fallback to creating a new context
user = self.users[0]
self.contexts[context_id] = Context( self.contexts[context_id] = Context(
id=context_id, id=context_id,
file_watcher=self.file_watcher, user=user,
prometheus_collector=self.prometheus_collector, rags=[ rag.model_copy() for rag in user.rags ],
tools=Tools.enabled_tools(Tools.tools)
) )
return self.contexts[context_id] 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. Create a new context with a unique ID and default settings.
Args: Args:
@ -992,16 +984,43 @@ class WebServer:
Returns: Returns:
A Context object with the specified ID and default settings. A Context object with the specified ID and default settings.
""" """
if not self.file_watcher: if not User.exists(username):
raise Exception("File watcher not initialized") raise ValueError(f"{username} does not exist.")
if not context_id:
context_id = str(uuid4()) # If username
logger.info(f"Creating new context with ID: {context_id}") matching_user = next((user for user in self.users if user.username == username), None)
context = Context( if matching_user:
id=context_id, user = matching_user
file_watcher=self.file_watcher, logger.info("Found matching user", user.model_dump(mode="json"))
prometheus_collector=self.prometheus_collector, 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): if os.path.exists(defines.resume_doc):
context.user_resume = open(defines.resume_doc, "r").read() 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(Resume(system_prompt = system_generate_resume))
# context.add_agent(JobDescription(system_prompt = system_job_description)) # context.add_agent(JobDescription(system_prompt = system_job_description))
# context.add_agent(FactCheck(system_prompt = system_fact_check)) # 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.") logger.info(f"{context.id} created and added to contexts.")
self.contexts[context.id] = context self.contexts[context.id] = context
@ -1029,21 +1046,18 @@ class WebServer:
if not context_id: if not context_id:
logger.warning("No context ID provided. Creating a new context.") 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: if context_id in self.contexts:
return self.contexts[context_id] return self.contexts[context_id]
logger.info(f"Context {context_id} is not yet loaded.") 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() @REQUEST_TIME.time()
async def generate_response( async def generate_response(
self, context: Context, agent: Agent, prompt: str, tunables: Tunables | None self, context: Context, agent: Agent, prompt: str, tunables: Tunables | None
) -> AsyncGenerator[Message, None]: ) -> AsyncGenerator[Message, None]:
if not self.file_watcher:
raise Exception("File watcher not initialized")
agent_type = agent.get_agent_type() agent_type = agent.get_agent_type()
logger.info(f"generate_response: type - {agent_type}") logger.info(f"generate_response: type - {agent_type}")
# Merge tunables to take agent defaults and override with user supplied settings # 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}") logger.info(f"Starting web server at http://{host}:{port}")
uvicorn.run(self.app, host=host, port=port, log_config=None) uvicorn.run(self.app, host=host, port=port, log_config=None)
except KeyboardInterrupt: except KeyboardInterrupt:
if self.observer: for user in self.users:
self.observer.stop() if user.observer:
if self.observer: user.observer.stop()
self.observer.join() for user in self.users:
if user.observer:
user.observer.join()
# %% # %%
@ -1101,8 +1117,6 @@ class WebServer:
# Main function to run everything # Main function to run everything
def main(): def main():
global model
# Parse command-line arguments # Parse command-line arguments
args = parse_args() args = parse_args()
@ -1114,11 +1128,7 @@ def main():
warnings.filterwarnings("ignore", category=UserWarning, module="umap.*") warnings.filterwarnings("ignore", category=UserWarning, module="umap.*")
llm = ollama.Client(host=args.ollama_server) # type: ignore llm = ollama.Client(host=args.ollama_server) # type: ignore
model = args.ollama_model web_server = WebServer(llm, args.ollama_model)
web_server = WebServer(llm, model)
web_server.run(host=args.web_host, port=args.web_port, use_reloader=False) web_server.run(host=args.web_host, port=args.web_port, use_reloader=False)
main() main()

View File

@ -1,22 +1,7 @@
# From /opt/backstory run: # From /opt/backstory run:
# python -m src.tests.test-context # python -m src.tests.test-context
import os from ..utils import Context
os.environ["TORCH_CPP_LOG_LEVEL"] = "ERROR" context = Context()
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)
json_data = context.model_dump(mode="json") json_data = context.model_dump(mode="json")
context = Context.model_validate(json_data) context = Context.model_validate(json_data)

View File

@ -8,10 +8,11 @@ import importlib
import json import json
from . import defines from . import defines
from .message import Message, Tunables, MessageMetaData
from .user import User
from .context import Context from .context import Context
from .conversation import Conversation from .conversation import Conversation
from .message import Message, Tunables, MessageMetaData from .rag import ChromaDBFileWatcher, ChromaDBGetResponse, start_file_watcher, RagEntry
from .rag import ChromaDBFileWatcher, ChromaDBGetResponse, start_file_watcher
from .setup_logging import setup_logging from .setup_logging import setup_logging
from .agents import class_registry, AnyAgent, Agent, __all__ as agents_all from .agents import class_registry, AnyAgent, Agent, __all__ as agents_all
from .metrics import Metrics from .metrics import Metrics
@ -25,11 +26,13 @@ __all__ = [
"Context", "Context",
"Conversation", "Conversation",
"Metrics", "Metrics",
"RagEntry",
"ChromaDBFileWatcher", "ChromaDBFileWatcher",
'ChromaDBGetResponse', 'ChromaDBGetResponse',
"start_file_watcher", "start_file_watcher",
"check_serializable", "check_serializable",
"logger", "logger",
"User",
] ]
__all__.extend(agents_all) # type: ignore __all__.extend(agents_all) # type: ignore

View File

@ -157,7 +157,7 @@ class Agent(BaseModel, ABC):
if message.tunables.enable_rag and message.prompt: if message.tunables.enable_rag and message.prompt:
# Gather RAG results, yielding each result # Gather RAG results, yielding each result
# as it becomes available # 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}") logger.info(f"RAG: {message.status} - {message.response}")
if message.status == "error": if message.status == "error":
yield message yield message
@ -172,7 +172,7 @@ class Agent(BaseModel, ABC):
message.preamble = {} message.preamble = {}
if rag_context: 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: if message.tunables.enable_context and self.context.user_resume:
message.preamble["resume"] = self.context.user_resume message.preamble["resume"] = self.context.user_resume

View File

@ -212,8 +212,7 @@ class JobDescription(Agent):
def generate_resume_from_skill_assessments( def generate_resume_from_skill_assessments(
self, self,
candidate_name, candidate_info,
candidate_contact_info,
skill_assessment_results, skill_assessment_results,
original_resume, original_resume,
): ):
@ -273,19 +272,18 @@ class JobDescription(Agent):
# Format contact info # Format contact info
contact_info_str = "" contact_info_str = ""
if candidate_contact_info: contact_items = []
contact_items = [] for key, value in candidate_info.get("contact_info", {}).items():
for key, value in candidate_contact_info.items(): if value:
if value: contact_items.append(f"{key}: {value}")
contact_items.append(f"{key}: {value}") contact_info_str = "\n".join(contact_items)
contact_info_str = "\n".join(contact_items)
# Build the system prompt # Build the system prompt
system_prompt = f"""You are a professional resume writer with expertise in highlighting candidate strengths and experiences. 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. Create a polished, concise, and ATS-friendly resume for the candidate based on the assessment data provided.
## CANDIDATE INFORMATION: ## CANDIDATE INFORMATION:
Name: {candidate_name} Name: {candidate_info.full_name}
{contact_info_str} {contact_info_str}
## SKILL ASSESSMENT RESULTS: ## SKILL ASSESSMENT RESULTS:
@ -381,14 +379,11 @@ Provide the resume in clean markdown format, ready for the candidate to use.
""" """
# Extract candidate information # Extract candidate information
candidate_name = candidate_info.get("name", "")
candidate_contact = candidate_info.get("contact_info", {})
original_resume = candidate_info.get("original_resume", "") original_resume = candidate_info.get("original_resume", "")
# Generate resume prompt # Generate resume prompt
system_prompt, prompt = self.generate_resume_from_skill_assessments( system_prompt, prompt = self.generate_resume_from_skill_assessments(
candidate_name, candidate_info,
candidate_contact,
skill_assessment_results.values(), skill_assessment_results.values(),
original_resume, original_resume,
) )
@ -874,18 +869,18 @@ IMPORTANT: Be factual and precise. If you cannot find strong evidence for this s
yield message yield message
def retrieve_rag_content(self, skill: str) -> tuple[str, ChromaDBGetResponse]: def retrieve_rag_content(self, skill: str) -> tuple[str, ChromaDBGetResponse]:
if self.context is None 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.file_watcher is None") raise ValueError("self.context or self.context.user.file_watcher is None")
try: try:
rag_results = "" rag_results = ""
rag_metadata = ChromaDBGetResponse() 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: if chroma_results:
query_embedding = np.array(chroma_results["query_embedding"]).flatten() query_embedding = np.array(chroma_results["query_embedding"]).flatten()
umap_2d = self.context.file_watcher.umap_model_2d.transform([query_embedding])[0] umap_2d = self.context.user.file_watcher.umap_model_2d.transform([query_embedding])[0]
umap_3d = self.context.file_watcher.umap_model_3d.transform([query_embedding])[0] umap_3d = self.context.user.file_watcher.umap_model_3d.transform([query_embedding])[0]
rag_metadata = ChromaDBGetResponse( rag_metadata = ChromaDBGetResponse(
query=skill, 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", []), metadatas=chroma_results.get("metadatas", []),
umap_embedding_2d=umap_2d.tolist(), umap_embedding_2d=umap_2d.tolist(),
umap_embedding_3d=umap_3d.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"]): for index, metadata in enumerate(chroma_results["metadatas"]):
@ -1057,15 +1052,9 @@ Content: { content }
"result": skill_assessment_results[skill_name], "result": skill_assessment_results[skill_name],
} }
# Extract header from original resume
candidate_info = { candidate_info = {
"name": "James Ketrenos", "full_name": self.context.user.full_name,
"contact_info": { "contact_info": self.context.user.contact_info
"email": "james@ketrenos.com",
"phone": "(503) 501 8281",
"location": "Beaverton, OR 97003",
},
"original_resume": resume,
} }
# Stage 2: Generate tailored resume # Stage 2: Generate tailored resume

View File

@ -1,48 +1,85 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, Field, model_validator # type: ignore from pydantic import BaseModel, Field, model_validator # type: ignore
from uuid import uuid4 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 from typing_extensions import Annotated, Union
import numpy as np # type: ignore import numpy as np # type: ignore
import logging import logging
from uuid import uuid4 from uuid import uuid4
from prometheus_client import CollectorRegistry, Counter # type: ignore
import traceback import traceback
from .message import Message, Tunables from . rag import RagEntry
from .rag import ChromaDBFileWatcher, ChromaDBGetResponse
from . import defines
from . import tools as Tools from . import tools as Tools
from .agents import AnyAgent from .agents import AnyAgent
from . import User
if TYPE_CHECKING:
from .user import User
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Context(BaseModel): class Context(BaseModel):
model_config = {"arbitrary_types_allowed": True} # Allow ChromaDBFileWatcher class Config:
# Required fields validate_by_name = True # Allow 'user' to be set via constructor
file_watcher: Optional[ChromaDBFileWatcher] = Field(default=None, exclude=True) arbitrary_types_allowed = True # Allow ChromaDBFileWatcher
prometheus_collector: Optional[CollectorRegistry] = Field(
default=None, exclude=True
)
# Optional fields
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid4()), 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}$", 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_resume: Optional[str] = None
user_job_description: Optional[str] = None user_job_description: Optional[str] = None
user_facts: 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 # Class managed fields
agents: List[Annotated[Union[*Agent.__subclasses__()], Field(discriminator="agent_type")]] = Field( # type: ignore agents: List[Annotated[Union[*Agent.__subclasses__()], Field(discriminator="agent_type")]] = Field( # type: ignore
default_factory=list 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 = <valid 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) processing: bool = Field(default=False, exclude=True)
# @model_validator(mode="before") # @model_validator(mode="before")
@ -64,74 +101,6 @@ class Context(BaseModel):
# agent.set_context(self) # agent.set_context(self)
return 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: 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. 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" summary += f"\nChat Name: {agent.name}\n"
return summary return summary
from .agents import Agent from .agents import Agent
Context.model_rebuild() Context.model_rebuild()

View File

@ -2,6 +2,14 @@ import os
ollama_api_url = "http://ollama:11434" # Default Ollama local endpoint 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 name License Notes
# model = "deepseek-r1:7b" # MIT Tool calls don"t work # model = "deepseek-r1:7b" # MIT Tool calls don"t work
# model = "gemma3:4b" # Gemma Requires newer ollama https://ai.google.dev/gemma/terms # 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 # Where to store session json files
context_dir = "/opt/backstory/sessions" 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 # Location of frontend container's build output mapped into the container
static_content = "/opt/backstory/frontend/deployed" static_content = "/opt/backstory/frontend/deployed"
@ -34,12 +39,10 @@ logging_level = os.getenv("LOGGING_LEVEL", "INFO").upper()
# RAG and Vector DB settings # RAG and Vector DB settings
## Where to read RAG content ## 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) 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 # Maximum number of entries for ChromaDB to find
default_rag_top_k = 30 default_rag_top_k = 80
# Cosine Distance Equivalent Similarity Retrieval Characteristics # Cosine Distance Equivalent Similarity Retrieval Characteristics
# 0.2 - 0.3 0.85 - 0.90 Very strict, highly precise results only # 0.2 - 0.3 0.85 - 0.90 Very strict, highly precise results only

View File

@ -37,6 +37,11 @@ __all__ = ["ChromaDBFileWatcher", "start_file_watcher", "ChromaDBGetResponse"]
DEFAULT_CHUNK_SIZE = 750 DEFAULT_CHUNK_SIZE = 750
DEFAULT_CHUNK_OVERLAP = 100 DEFAULT_CHUNK_OVERLAP = 100
class RagEntry(BaseModel):
name: str
description: str = ""
enabled: bool = True
class ChromaDBGetResponse(BaseModel): class ChromaDBGetResponse(BaseModel):
name: str = "" name: str = ""
size: int = 0 size: int = 0
@ -56,7 +61,7 @@ class ChromaDBGetResponse(BaseModel):
@field_validator("embeddings", "query_embedding", "umap_embedding_2d", "umap_embedding_3d") @field_validator("embeddings", "query_embedding", "umap_embedding_2d", "umap_embedding_3d")
@classmethod @classmethod
def validate_embeddings(cls, value, field): 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: if value is None:
return value return value
if isinstance(value, np.ndarray): if isinstance(value, np.ndarray):
@ -83,8 +88,8 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
llm, llm,
watch_directory, watch_directory,
loop, loop,
persist_directory=None, persist_directory,
collection_name="documents", collection_name,
chunk_size=DEFAULT_CHUNK_SIZE, chunk_size=DEFAULT_CHUNK_SIZE,
chunk_overlap=DEFAULT_CHUNK_OVERLAP, chunk_overlap=DEFAULT_CHUNK_OVERLAP,
recreate=False, recreate=False,
@ -125,11 +130,13 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
self.processing_files = set() self.processing_files = set()
@property @property
def collection(self): def collection(self) -> Collection:
return self._collection return self._collection
@property @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 return self._umap_collection
@property @property
@ -342,7 +349,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
# During initialization # During initialization
logging.info( 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"]) vectors = np.array(self._umap_collection["embeddings"])
self._umap_model_2d = umap.UMAP( self._umap_model_2d = umap.UMAP(
@ -358,7 +365,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
# ) # Should be 2 # ) # Should be 2
logging.info( 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( self._umap_model_3d = umap.UMAP(
n_components=3, n_components=3,
@ -374,6 +381,10 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
def _get_vector_collection(self, recreate=False) -> Collection: def _get_vector_collection(self, recreate=False) -> Collection:
"""Get or create a ChromaDB 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 # Initialize ChromaDB client
chroma_client = chromadb.PersistentClient( # type: ignore chroma_client = chromadb.PersistentClient( # type: ignore
path=self.persist_directory, path=self.persist_directory,
@ -402,10 +413,6 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
name=self.collection_name, metadata={"hnsw:space": "cosine"} 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: def get_embedding(self, text: str) -> np.ndarray:
"""Generate and normalize an embedding for the given text.""" """Generate and normalize an embedding for the given text."""
@ -673,8 +680,8 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
def start_file_watcher( def start_file_watcher(
llm, llm,
watch_directory, watch_directory,
persist_directory=None, persist_directory,
collection_name="documents", collection_name,
initialize=False, initialize=False,
recreate=False, recreate=False,
): ):
@ -693,7 +700,7 @@ def start_file_watcher(
file_watcher = ChromaDBFileWatcher( file_watcher = ChromaDBFileWatcher(
llm, llm,
watch_directory, watch_directory=watch_directory,
loop=loop, loop=loop,
persist_directory=persist_directory, persist_directory=persist_directory,
collection_name=collection_name, collection_name=collection_name,
@ -719,31 +726,3 @@ def start_file_watcher(
logging.info(f"Started watching directory: {watch_directory}") logging.info(f"Started watching directory: {watch_directory}")
return observer, file_watcher 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()

216
src/utils/user.py Normal file
View File

@ -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()

0
users-prod/.keep Normal file
View File

0
users/.keep Normal file
View File

4
users/eliza/info.json Normal file
View File

@ -0,0 +1,4 @@
{
"first_name": "Eliza",
"last_name": "Morgan"
}

View File

@ -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

View File

@ -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.

View File

@ -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

Binary file not shown.

View File

@ -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 Botanicals 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 Kincaids 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 Fenders 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)