diff --git a/frontend/public/docs/about.md b/frontend/public/docs/about.md index 59dba18..57ef6c1 100644 --- a/frontend/public/docs/about.md +++ b/frontend/public/docs/about.md @@ -23,10 +23,10 @@ The backstory about Backstory... ## Some questions I've been asked -Q. +Q. A. I could; but I don't want to store your data. I also don't want to have to be on the hook for support of this service. I like it, it's fun, but it's not what I want as my day-gig, you know? If it was, I wouldn't be looking for a job... -Q. +Q. A. Try it. See what you find out :) \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d37ad52..e504ae0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,11 +19,12 @@ import { SxProps } from '@mui/material'; import { ResumeBuilder } from './ResumeBuilder'; -import { Message, ChatQuery, MessageList } from './Message'; +import { Message, MessageList } from './Message'; import { Snack, SeverityType } from './Snack'; import { VectorVisualizer } from './VectorVisualizer'; import { Controls } from './Controls'; import { Conversation, ConversationHandle } from './Conversation'; +import { ChatQuery, QueryOptions } from './ChatQuery'; import { Scrollable } from './AutoScroll'; import { BackstoryTab } from './BackstoryTab'; @@ -112,9 +113,9 @@ const App = () => { fetchAbout(); }, [about, setAbout]) - const handleSubmitChatQuery = (query: string) => { - console.log(`handleSubmitChatQuery: ${query} -- `, chatRef.current ? ' sending' : 'no handler'); - chatRef.current?.submitQuery(query); + const handleSubmitChatQuery = (prompt: string, tunables?: QueryOptions) => { + console.log(`handleSubmitChatQuery: ${prompt} ${tunables || {}} -- `, chatRef.current ? ' sending' : 'no handler'); + chatRef.current?.submitQuery(prompt, tunables); setActiveTab(0); }; @@ -137,10 +138,10 @@ const App = () => { const backstoryQuestions = [ - - - - + + + + , diff --git a/frontend/src/ChatQuery.tsx b/frontend/src/ChatQuery.tsx new file mode 100644 index 0000000..3ebe0ea --- /dev/null +++ b/frontend/src/ChatQuery.tsx @@ -0,0 +1,48 @@ +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; + +type QueryOptions = { + enable_rag?: boolean, + enable_tools?: boolean, + enable_context?: boolean, +}; + +interface ChatQueryInterface { + prompt: string, + tunables?: QueryOptions, + submitQuery?: (prompt: string, tunables?: QueryOptions) => void +} + +const ChatQuery = (props : ChatQueryInterface) => { + const { prompt, submitQuery } = props; + let tunables = props.tunables; + + if (typeof (tunables) === "string") { + tunables = JSON.parse(tunables); + } + console.log(tunables); + + if (submitQuery === undefined) { + return ({prompt}); + } + return ( + + ); +} + +export type { + ChatQueryInterface, + QueryOptions, +}; + +export { + ChatQuery, +}; + diff --git a/frontend/src/Controls.tsx b/frontend/src/Controls.tsx index f922417..87d7192 100644 --- a/frontend/src/Controls.tsx +++ b/frontend/src/Controls.tsx @@ -319,7 +319,7 @@ const Controls = ({ sessionId, setSnack, connectionBase }: ControlsParams) => { }; return (
- + {/* You can change the information available to the LLM by adjusting the following settings: @@ -414,7 +414,8 @@ const Controls = ({ sessionId, setSnack, connectionBase }: ControlsParams) => { ) } - + */} + }> System Information @@ -426,8 +427,9 @@ const Controls = ({ sessionId, setSnack, connectionBase }: ControlsParams) => { - - + + {/* + */}
); } diff --git a/frontend/src/Conversation.tsx b/frontend/src/Conversation.tsx index e3350e4..dd3d43d 100644 --- a/frontend/src/Conversation.tsx +++ b/frontend/src/Conversation.tsx @@ -13,7 +13,7 @@ import { SetSnackType } from './Snack'; import { ContextStatus } from './ContextStatus'; import { useAutoScrollToBottom } from './AutoScroll'; import { DeleteConfirmation } from './DeleteConfirmation'; - +import { QueryOptions } from './ChatQuery'; import './Conversation.css'; const loadingMessage: MessageData = { "role": "status", "content": "Establishing connection with server..." }; @@ -21,7 +21,7 @@ const loadingMessage: MessageData = { "role": "status", "content": "Establishing type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check'; interface ConversationHandle { - submitQuery: (query: string) => void; + submitQuery: (prompt: string, options?: QueryOptions) => void; } interface BackstoryMessage { @@ -54,6 +54,7 @@ interface ConversationProps { setSnack: SetSnackType, // Callback to display snack popups defaultPrompts?: React.ReactElement[], // Set of Elements to display after the TextField defaultQuery?: string, // Default text to populate the TextField input + emptyPrompt?: string, // If input is not shown and an action is taken, send this prompt preamble?: MessageList, // Messages to display at start of Conversation until Action has been invoked hidePreamble?: boolean, // Whether to hide the preamble after an Action has been invoked hideDefaultPrompts?: boolean, // Whether to hide the defaultPrompts after an Action has been invoked @@ -67,6 +68,7 @@ const Conversation = forwardRef(({ className, type, prompt, + emptyPrompt, actionLabel, resetAction, multiline, @@ -256,8 +258,8 @@ const Conversation = forwardRef(({ }; useImperativeHandle(ref, () => ({ - submitQuery: (query: string) => { - sendQuery(query); + submitQuery: (query: string, tunables?: QueryOptions) => { + sendQuery(query, tunables); } })); @@ -303,38 +305,34 @@ const Conversation = forwardRef(({ } }; - const sendQuery = async (query: string) => { - query = query.trim(); + const sendQuery = async (request: string, options?: QueryOptions) => { + request = request.trim(); // If the query was empty, a default query was provided, // and there is no prompt for the user, send the default query. - if (!query && defaultQuery && !prompt) { - query = defaultQuery.trim(); + if (!request && defaultQuery && !prompt) { + request = defaultQuery.trim(); } - // If the query is empty, and a prompt was provided, do not - // send an empty query. - if (!query && prompt) { + // Do not send an empty query. + if (!request) { return; } setNoInteractions(false); - if (query) { - setConversation([ - ...conversationRef.current, - { - role: 'user', - origin: type, - content: query, - disableCopy: true - } - ]); - } + setConversation([ + ...conversationRef.current, + { + role: 'user', + origin: type, + content: request, + disableCopy: true + } + ]); // Add a small delay to ensure React has time to update the UI await new Promise(resolve => setTimeout(resolve, 0)); - console.log(conversation); // Clear input setQuery(''); @@ -353,13 +351,25 @@ const Conversation = forwardRef(({ await new Promise(resolve => setTimeout(resolve, 0)); // Make the fetch request with proper headers + let query; + if (options) { + query = { + options: options, + prompt: request.trim() + } + } else { + query = { + prompt: request.trim() + } + } + const response = await fetch(connectionBase + `/api/chat/${sessionId}/${type}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, - body: JSON.stringify({ role: 'user', content: query.trim() }), + body: JSON.stringify(query) }); // We'll guess that the response will be around 500 tokens... @@ -383,14 +393,14 @@ const Conversation = forwardRef(({ let buffer = ''; const process_line = async (line: string) => { - const update = JSON.parse(line); + let update = JSON.parse(line); switch (update.status) { case 'done': console.log('Done processing:', update); // Replace processing message with final result if (onResponse) { - update.message = onResponse(update); + update = onResponse(update); } setProcessingMessage(undefined); const backstoryMessage: BackstoryMessage = update; @@ -451,6 +461,7 @@ const Conversation = forwardRef(({ await process_line(line); } catch (e) { setSnack("Error processing query", "error") + console.error(e); } } } @@ -461,6 +472,7 @@ const Conversation = forwardRef(({ await process_line(buffer); } catch (e) { setSnack("Error processing query", "error") + console.error(e); } } @@ -579,7 +591,7 @@ const Conversation = forwardRef(({ export type { ConversationProps, - ConversationHandle + ConversationHandle, }; export { diff --git a/frontend/src/Message.tsx b/frontend/src/Message.tsx index 01fff20..1e2fa88 100644 --- a/frontend/src/Message.tsx +++ b/frontend/src/Message.tsx @@ -76,11 +76,6 @@ interface MessageProps { className?: string, }; -interface ChatQueryInterface { - text: string, - submitQuery?: (text: string) => void -} - interface MessageMetaProps { metadata: MessageMetaData, messageProps: MessageProps @@ -157,7 +152,7 @@ const MessageMeta = (props: MessageMetaProps) => { {tool.name} - + { if (typeof (children) === "string" && children.match("\n")) { @@ -209,7 +204,7 @@ const MessageMeta = (props: MessageMetaProps) => {
- + { if (typeof (children) === "string" && children.match("\n")) { @@ -223,22 +218,6 @@ const MessageMeta = (props: MessageMetaProps) => { ); }; -const ChatQuery = ({ text, submitQuery }: ChatQueryInterface) => { - if (submitQuery === undefined) { - return ({text}); - } - return ( - - ); -} - const Message = (props: MessageProps) => { const { message, submitQuery, isFullWidth, sx, className } = props; const [expanded, setExpanded] = useState(false); @@ -323,14 +302,12 @@ const Message = (props: MessageProps) => { export type { MessageProps, MessageList, - ChatQueryInterface, MessageData, MessageRoles }; export { Message, - ChatQuery, MessageMeta }; diff --git a/frontend/src/ResumeBuilder.tsx b/frontend/src/ResumeBuilder.tsx index 45f7ec1..f69a420 100644 --- a/frontend/src/ResumeBuilder.tsx +++ b/frontend/src/ResumeBuilder.tsx @@ -18,7 +18,7 @@ import { } from '@mui/icons-material'; import { SxProps, Theme } from '@mui/material'; -import { ChatQuery } from './Message'; +import { ChatQuery } from './ChatQuery'; import { MessageList, MessageData } from './Message'; import { SetSnackType } from './Snack'; import { Conversation } from './Conversation'; @@ -97,6 +97,12 @@ const ResumeBuilder: React.FC = ({ if (messages === undefined || messages.length === 0) { return []; } + console.log("filterJobDescriptionMessages disabled") + if (messages.length > 1) { + setHasResume(true); + } + + return messages; let reduced = messages.filter((m, i) => { const keep = (m.metadata?.origin || m.origin || "no origin") === 'job_description'; @@ -135,6 +141,11 @@ const ResumeBuilder: React.FC = ({ if (messages === undefined || messages.length === 0) { return []; } + console.log("filterResumeMessages disabled") + if (messages.length > 3) { + setHasFacts(true); + } + return messages; let reduced = messages.filter((m, i) => { const keep = (m.metadata?.origin || m.origin || "no origin") === 'resume'; @@ -182,6 +193,9 @@ const ResumeBuilder: React.FC = ({ if (messages === undefined || messages.length === 0) { return []; } + console.log("filterFactsMessages disabled") + return messages; + // messages.forEach((m, i) => console.log(`filterFactsMessages: ${i + 1}:`, m)) const reduced = messages.filter(m => { @@ -240,8 +254,8 @@ const ResumeBuilder: React.FC = ({ console.log('renderJobDescriptionView'); const jobDescriptionQuestions = [ - - + + , ]; @@ -289,8 +303,8 @@ const ResumeBuilder: React.FC = ({ const renderResumeView = useCallback((small: boolean) => { const resumeQuestions = [ - - + + , ]; @@ -298,9 +312,9 @@ const ResumeBuilder: React.FC = ({ return = ({ prompt: "Ask a question about this job resume...", resetLabel: `job description${hasFacts ? ", resume, and fact check" : hasResume ? " and resume" : ""}`, messageFilter: filterResumeMessages, - defaultPrompts: resumeQuestions, - resetAction: resetResume, onResponse: resumeResponse, + resetAction: resetResume, sessionId, connectionBase, setSnack, + defaultPrompts: resumeQuestions, }} /> } @@ -336,7 +350,7 @@ const ResumeBuilder: React.FC = ({ const renderFactCheckView = useCallback((small: boolean) => { const factsQuestions = [ - + , ]; diff --git a/frontend/src/StyledMarkdown.tsx b/frontend/src/StyledMarkdown.tsx index 30bf6eb..6346e1e 100644 --- a/frontend/src/StyledMarkdown.tsx +++ b/frontend/src/StyledMarkdown.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { MuiMarkdown } from 'mui-markdown'; import { useTheme } from '@mui/material/styles'; import { Link } from '@mui/material'; -import { ChatQuery } from './Message'; +import { ChatQuery, QueryOptions } from './ChatQuery'; interface StyledMarkdownProps { className?: string, content: string, - submitQuery?: (query: string) => void, + submitQuery?: (prompt: string, tunables?: QueryOptions) => void, [key: string]: any, // For any additional props }; @@ -38,7 +38,7 @@ const StyledMarkdown: React.FC = ({ className, content, sub options.overrides.ChatQuery = { component: ChatQuery, props: { - submitQuery + submitQuery, }, }; } diff --git a/frontend/src/VectorVisualizer.tsx b/frontend/src/VectorVisualizer.tsx index e7d38e8..c4f6035 100644 --- a/frontend/src/VectorVisualizer.tsx +++ b/frontend/src/VectorVisualizer.tsx @@ -158,7 +158,7 @@ const VectorVisualizer: React.FC = (props: VectorVisualiz useEffect(() => { if (!result || !result.embeddings) return; if (result.embeddings.length === 0) return; - console.log('Result:', result); + const vectors: (number[])[] = [...result.embeddings]; const documents = [...result.documents || []]; const metadatas = [...result.metadatas || []]; diff --git a/src/server.py b/src/server.py index 4ee2c83..490374f 100644 --- a/src/server.py +++ b/src/server.py @@ -18,6 +18,7 @@ import math import warnings from typing import Any from collections import deque +from datetime import datetime from uuid import uuid4 @@ -53,6 +54,7 @@ from utils import ( tools as Tools, Context, Conversation, Message, Agent, + Tunables, defines, logger, ) @@ -64,26 +66,8 @@ rags = [ # { "name": "LKML", "enabled": False, "description": "Full associative data for entire LKML mailing list archive." }, ] -system_message = f""" -Launched on {Tools.DateTime()}. - -When answering queries, follow these steps: - -- First analyze the query to determine if real-time information from the tools might be helpful -- Even when <|context|> is provided, consider whether the tools would provide more current or comprehensive information -- Use the provided tools whenever they would enhance your response, regardless of whether context is also available -- When presenting weather forecasts, include relevant emojis immediately before the corresponding text. For example, for a sunny day, say \"☀️ Sunny\" or if the forecast says there will be \"rain showers, say \"🌧️ Rain showers\". Use this mapping for weather emojis: Sunny: ☀️, Cloudy: ☁️, Rainy: 🌧️, Snowy: ❄️ -- When both <|context|> and tool outputs are relevant, synthesize information from both sources to provide the most complete answer -- Always prioritize the most up-to-date and relevant information, whether it comes from <|context|> or tools -- If <|context|> and tool outputs contain conflicting information, prefer the tool outputs as they likely represent more current data -- If there is information in the <|context|>, <|job_description|>, or <|context|> sections to enhance the answer, incorporate it seamlessly and refer to it as 'the latest information' or 'recent data' instead of mentioning '<|context|>' (etc.) or quoting it directly. -- Avoid phrases like 'According to the <|context|>' or similar references to the <|context|>, <|job_description|>, or <|context|> tags. - -Always use tools and <|context|> when possible. Be concise, and never make up information. If you do not know the answer, say so. -""" - system_message_old = f""" -Launched on {Tools.DateTime()}. +Launched on {datetime.now().isoformat()}. When answering queries, follow these steps: @@ -98,53 +82,9 @@ When answering queries, follow these steps: Always use tools and <|context|> when possible. Be concise, and never make up information. If you do not know the answer, say so. """.strip() -system_generate_resume = f""" -Launched on {Tools.DateTime()}. - -You are a professional resume writer. Your task is to write a concise, polished, and tailored resume for a specific job based only on the individual's <|context|>. - -When answering queries, follow these steps: - -- You must not invent or assume any inforation not explicitly present in the <|context|>. -- Analyze the <|job_description|> to identify skills required for the job. -- Use the <|job_description|> provided to guide the focus, tone, and relevant skills or experience to highlight from the <|context|>. -- Identify and emphasize the experiences, achievements, and responsibilities from the <|context|> that best align with the <|job_description|>. -- Only provide information from <|context|> items if it is relevant to the <|job_description|>. -- Do not use the <|job_description|> skills unless listed in <|context|>. -- Do not include any information unless it is provided in <|context|>. -- Use the <|context|> to create a polished, professional resume. -- Do not list any locations or mailing addresses in the resume. -- If there is information in the <|context|>, <|job_description|>, <|context|>, or <|resume|> sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '<|job_description|>' (etc.) or quoting it directly. -- Avoid phrases like 'According to the <|context|>' or similar references to the <|context|>, <|job_description|>, or <|context|> tags. -- Ensure the langauge is clear, concise, and aligned with industry standards for professional resumes. - -Structure the resume professionally with the following sections where applicable: - -* Name: Use full name -* Professional Summary: A 2-4 sentence overview tailored to the job. -* Skills: A bullet list of key skills derived from the work history and relevant to the job. -* Professional Experience: A detailed list of roles, achievements, and responsibilities from <|context|> that relate to the <|job_description|>. -* Education: Include only if available in the work history. -* Notes: Indicate the initial draft of the resume was generated using the Backstory application. - -""".strip() - -system_fact_check = f""" -Launched on {Tools.DateTime()}. - -You are a professional resume fact checker. Your task is to identify any inaccuracies in the <|resume|> based on the individual's <|context|>. - -If there are inaccuracies, list them in a bullet point format. - -When answering queries, follow these steps: -- You must not invent or assume any information not explicitly present in the <|context|>. -- Analyze the <|resume|> to identify any discrepancies or inaccuracies based on the <|context|>. -- If there is information in the <|context|>, <|job_description|>, <|context|>, or <|resume|> sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '<|job_description|>' (etc.) or quoting it directly. -- Avoid phrases like 'According to the <|context|>' or similar references to the <|context|>, <|job_description|>, <|resume|>, or <|context|> tags. -""".strip() system_fact_check_QA = f""" -Launched on {Tools.DateTime()}. +Launched on {datetime.now().isoformat()}. You are a professional resume fact checker. @@ -153,18 +93,6 @@ You are provided with a <|resume|> which was generated by you, the <|context|> y Your task is to answer questions about the <|fact_check|> you generated based on the <|resume|> and <|context>. """ -system_job_description = f""" -Launched on {Tools.DateTime()}. - -You are a hiring and job placing specialist. Your task is to answers about a job description. - -When answering queries, follow these steps: -- Analyze the <|job_description|> to provide insights for the asked question. -- If any financial information is requested, be sure to account for inflation. -- If there is information in the <|context|>, <|job_description|>, <|context|>, or <|resume|> sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '<|job_description|>' (etc.) or quoting it directly. -- Avoid phrases like 'According to the <|context|>' or similar references to the <|context|>, <|job_description|>, <|resume|>, or <|context|> tags. -""".strip() - def get_installed_ram(): try: with open("/proc/meminfo", "r") as f: @@ -440,27 +368,27 @@ class WebServer: match reset_operation: case "system_prompt": logger.info(f"Resetting {reset_operation}") - match agent_type: - case "chat": - prompt = system_message - case "job_description": - prompt = system_generate_resume - case "resume": - prompt = system_generate_resume - case "fact_check": - prompt = system_message - case _: - prompt = system_message + # match agent_type: + # case "chat": + # prompt = system_message + # case "job_description": + # prompt = system_generate_resume + # case "resume": + # prompt = system_generate_resume + # case "fact_check": + # prompt = system_message + # case _: + # prompt = system_message - agent.system_prompt = prompt - response["system_prompt"] = { "system_prompt": prompt } + # agent.system_prompt = prompt + # response["system_prompt"] = { "system_prompt": prompt } case "rags": logger.info(f"Resetting {reset_operation}") context.rags = rags.copy() response["rags"] = context.rags case "tools": logger.info(f"Resetting {reset_operation}") - context.tools = Tools.default_tools(Tools.tools) + context.tools = Tools.enabled_tools(Tools.tools) response["tools"] = context.tools case "history": reset_map = { @@ -579,27 +507,48 @@ class WebServer: @self.app.post("/api/chat/{context_id}/{agent_type}") async def post_chat_endpoint(context_id: str, agent_type: str, request: Request): logger.info(f"{request.method} {request.url.path}") + 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: - 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) try: - data = await request.json() agent = context.get_agent(agent_type) - if not agent and agent_type == "job_description": - logger.info(f"Agent {agent_type} not found. Returning empty history.") - # Create a new agent if it doesn't exist - agent = context.get_or_create_agent("job_description", system_prompt=system_generate_resume, job_description=data["content"]) except Exception as e: logger.info(f"Attempt to create agent type: {agent_type} failed", e) return JSONResponse({ "error": f"{agent_type} is not recognized", "context": context.id }, status_code=404) + query = await request.json() + prompt = query["prompt"] + if not isinstance(prompt, str) or len(prompt) == 0: + logger.info(f"Prompt is empty") + return JSONResponse({"error": "Prompt can not be empty"}, status_code=400) + try: + options = Tunables(**query["options"]) if "options" in query else None + except Exception as e: + logger.info(f"Attempt to set tunables failed: {query['options']}.", e) + return JSONResponse({"error": f"Invalid options: {query['options']}"}, status_code=400) + + if not agent: + # job_description is the only agent that is dynamically generated from a + # Rest API endpoint. + # - 'chat' is created on context creation. + # - 'resume' is created on actions by 'job_description' + # - 'fact_check' is created on ations by 'fact_check' + match agent_type: + case "job_description": + logger.info(f"Agent {agent_type} not found. Returning empty history.") + agent = context.get_or_create_agent("job_description", job_description=prompt) + case _: + logger.info(f"Invalid agent creation sequence for {agent_type}. Returning error.") + return JSONResponse({ "error": f"{agent_type} is not recognized", "context": context.id }, status_code=404) + # Create a custom generator that ensures flushing async def flush_generator(): logging.info(f"Message starting. Streaming partial results.") - async for message in self.generate_response(context=context, agent=agent, content=data["content"]): + async for message in self.generate_response(context=context, agent=agent, prompt=prompt, options=options): if message.status != "done": result = { "status": message.status, @@ -607,11 +556,15 @@ class WebServer: } else: logging.info(f"Message complete. Providing full response.") - result = message.model_dump(mode='json') + try: + result = message.model_dump(by_alias=True, mode='json') + except Exception as e: + result = { "status": "error", "response": e } + exit(1) + # Convert to JSON and add newline result = json.dumps(result) + "\n" message.network_packets += 1 message.network_bytes += len(result) - # Convert to JSON and add newline yield result # Explicitly flush after each yield await asyncio.sleep(0) # Allow the event loop to process the write @@ -653,7 +606,7 @@ class WebServer: if not agent: logger.info(f"Agent {agent_type} not found. Returning empty history.") return JSONResponse({ "messages": [] }) - logger.info(f"History for {agent_type} contains {len(agent.conversation.messages)} entries.") + logger.info(f"History for {agent_type} contains {len(agent.conversation)} entries.") return agent.conversation except Exception as e: logger.error(f"get_history error: {str(e)}") @@ -735,7 +688,7 @@ class WebServer: # Serialize the data to JSON and write to file with open(file_path, "w") as f: - f.write(context.model_dump_json()) + f.write(context.model_dump_json(by_alias=True)) return context_id @@ -800,13 +753,12 @@ class WebServer: if os.path.exists(defines.resume_doc): context.user_resume = open(defines.resume_doc, "r").read() - context.get_or_create_agent( - agent_type="chat", - system_prompt=system_message) + context.get_or_create_agent(agent_type="chat") + # system_prompt=system_message) # 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.default_tools(Tools.tools) + context.tools = Tools.enabled_tools(Tools.tools) context.rags = rags.copy() logger.info(f"{context.id} created and added to contexts.") @@ -814,73 +766,6 @@ class WebServer: self.save_context(context.id) return context - def get_optimal_ctx_size(self, context, messages, ctx_buffer = 4096): - ctx = round(context + len(str(messages)) * 3 / 4) - return max(defines.max_context, min(2048, ctx + ctx_buffer)) - - # %% - # async def handle_tool_calls(self, message): - # """ - # Process tool calls and yield status updates along the way. - # The last yielded item will be a tuple containing (tool_result, tools_used). - # """ - # tools_used = [] - # all_responses = [] - - # for i, tool_call in enumerate(message["tool_calls"]): - # arguments = tool_call["function"]["arguments"] - # tool = tool_call["function"]["name"] - - # # Yield status update before processing each tool - # yield {"status": "processing", "message": f"Processing tool {i+1}/{len(message['tool_calls'])}: {tool}..."} - - # # Process the tool based on its type - # match tool: - # case "TickerValue": - # ticker = arguments.get("ticker") - # if not ticker: - # ret = None - # else: - # ret = Tools.TickerValue(ticker) - # tools_used.append({ "tool": f"{tool}({ticker})", "result": ret}) - - # case "AnalyzeSite": - # url = arguments.get("url") - # question = arguments.get("question", "what is the summary of this content?") - - # # Additional status update for long-running operations - # yield {"status": "processing", "message": f"Retrieving and summarizing content from {url}..."} - # ret = await Tools.AnalyzeSite(llm=self.llm, model=self.model, url=url, question=question) - # tools_used.append({ "tool": f"{tool}('{url}', '{question}')", "result": ret }) - - # case "DateTime": - # tz = arguments.get("timezone") - # ret = Tools.DateTime(tz) - # tools_used.append({ "tool": f"{tool}('{tz}')", "result": ret }) - - # case "WeatherForecast": - # city = arguments.get("city") - # state = arguments.get("state") - - # yield {"status": "processing", "message": f"Fetching weather data for {city}, {state}..."} - # ret = Tools.WeatherForecast(city, state) - # tools_used.append({ "tool": f"{tool}('{city}', '{state}')", "result": ret }) - - # case _: - # ret = None - - # # Build response for this tool - # tool_response = { - # "role": "tool", - # "content": str(ret), - # "name": tool_call["function"]["name"] - # } - # all_responses.append(tool_response) - - # # Yield the final result as the last item - # final_result = all_responses[0] if len(all_responses) == 1 else all_responses - # yield (final_result, tools_used) - def upsert_context(self, context_id = None) -> Context: """ Upsert a context based on the provided context_id. @@ -899,74 +784,34 @@ class WebServer: logger.info(f"Context {context_id} is not yet loaded.") return self.load_or_create_context(context_id) - - def generate_rag_results(self, context, content): - if not self.file_watcher: - raise Exception("File watcher not initialized") - results_found = False - - for rag in context.rags: - if rag["enabled"] and rag["name"] == "JPK": # Only support JPK rag right now... - yield {"status": "processing", "message": f"Checking RAG context {rag['name']}..."} - chroma_results = self.file_watcher.find_similar(query=content, top_k=10) - if chroma_results: - results_found = True - chroma_embedding = np.array(chroma_results["query_embedding"]).flatten() # Ensure correct shape - logger.info(f"Chroma embedding shape: {chroma_embedding.shape}") - - umap_2d = self.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])[0].tolist() - logger.info(f"UMAP 3D output: {umap_3d}, length: {len(umap_3d)}") # Debug output - - yield { - **chroma_results, - "name": rag["name"], - "umap_embedding_2d": umap_2d, - "umap_embedding_3d": umap_3d - } - - if not results_found: - yield {"status": "complete", "message": "No RAG context found"} - yield { - "rag": None, - "documents": [], - "embeddings": [], - "umap_embedding_2d": [], - "umap_embedding_3d": [] - } - else: - yield {"status": "complete", "message": "RAG processing complete"} - - async def generate_response(self, context : Context, agent : Agent, content : str) -> AsyncGenerator[Message, None]: + async def generate_response(self, context : Context, agent : Agent, prompt : str, options: 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} prompt - {content}") - if agent_type == "chat": - message = Message(prompt=content) - async for message in agent.prepare_message(message): - # logger.info(f"{agent_type}.prepare_message: {value.status} - {value.response}") - if message.status == "error": - yield message - return - if message.status != "done": - yield message - async for message in agent.process_message(self.llm, self.model, message): - if message.status == "error": - yield message - return - if message.status != "done": - yield message - logger.info(f"{agent_type}.process_message: {message.status} {f'...{message.response[-20:]}' if len(message.response) > 20 else message.response}") - message.status = "done" - yield message - return - - return + logger.info(f"generate_response: type - {agent_type}") + message = Message(prompt=prompt, options=agent.tunables) + if options: + message.tunables = options + + async for message in agent.prepare_message(message): + # logger.info(f"{agent_type}.prepare_message: {value.status} - {value.response}") + if message.status == "error": + yield message + return + if message.status != "done": + yield message + async for message in agent.process_message(self.llm, self.model, message): + if message.status == "error": + yield message + return + if message.status != "done": + yield message + logger.info(f"{agent_type}.process_message: {message.status} {f'...{message.response[-20:]}' if len(message.response) > 20 else message.response}") + message.status = "done" + yield message + return if self.processing: logger.info("TODO: Implement delay queing; busy for same agent, otherwise return queue size and estimated wait time") @@ -1124,7 +969,7 @@ Use to the above information to respond to this prompt: stuffingMessage.response = "Job description stored to use in future queries." stuffingMessage.metadata["origin"] = "job_description" stuffingMessage.metadata["display"] = "hide" - conversation.add_message(stuffingMessage) + conversation.add(stuffingMessage) message.add_action("generate_resume") @@ -1210,7 +1055,7 @@ Use the above <|resume|> and <|job_description|> to answer this query: stuffingMessage.metadata["display"] = "hide" stuffingMessage.actions = [ "fact_check" ] logger.info("TODO: Switch this to use actions to keep the UI from showingit") - conversation.add_message(stuffingMessage) + conversation.add(stuffingMessage) # For all future calls to job_description, use the system_job_description logger.info("TODO: Create a system_resume_QA prompt to use for the resume agent") @@ -1226,7 +1071,7 @@ Use the above <|resume|> and <|job_description|> to answer this query: case _: raise Exception(f"Invalid chat agent_type: {agent_type}") - conversation.add_message(message) + conversation.add(message) # llm_history.append({"role": "user", "content": message.preamble + content}) # user_history.append({"role": "user", "content": content, "origin": message.metadata["origin"]}) # message.metadata["full_query"] = llm_history[-1]["content"] diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 4dcb54a..327ab4f 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -5,13 +5,14 @@ import importlib from . import defines from . context import Context from . conversation import Conversation -from . message import Message +from . message import Message, Tunables from . rag import ChromaDBFileWatcher, start_file_watcher from . setup_logging import setup_logging from . agents import class_registry, AnyAgent, Agent, __all__ as agents_all __all__ = [ 'Agent', + 'Tunables', 'Context', 'Conversation', 'Message', diff --git a/src/utils/agents/__init__.py b/src/utils/agents/__init__.py index 7b002c4..a153b57 100644 --- a/src/utils/agents/__init__.py +++ b/src/utils/agents/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import TypeAlias, Dict, Tuple +from typing import TypeAlias, Dict, Tuple, Optional import importlib import pathlib import inspect @@ -9,10 +9,9 @@ from .. setup_logging import setup_logging from .. import defines from . base import Agent - logger = setup_logging(defines.logging_level) -__all__ = [ "AnyAgent", "registry", "class_registry" ] +__all__ = [ "AnyAgent", "Agent", "registry", "class_registry" ] # Type alias for Agent or any subclass AnyAgent: TypeAlias = Agent # BaseModel covers Agent and subclasses diff --git a/src/utils/agents/base.py b/src/utils/agents/base.py index ce3a838..5ccae62 100644 --- a/src/utils/agents/base.py +++ b/src/utils/agents/base.py @@ -4,16 +4,15 @@ from typing import ( Literal, get_args, List, AsyncGenerator, TYPE_CHECKING, Optional, ClassVar, Any, TypeAlias, Dict, Tuple ) -from abc import ABC -from .. setup_logging import setup_logging -from .. import defines -from abc import ABC -import logging -from .. message import Message -from .. tools import ( TickerValue, WeatherForecast, AnalyzeSite, DateTime, llm_tools ) # type: ignore -- dynamically added to __all__ import json import time import inspect +from abc import ABC + +from .. setup_logging import setup_logging +from .. import defines +from .. message import Message +from .. tools import ( TickerValue, WeatherForecast, AnalyzeSite, DateTime, llm_tools ) # type: ignore -- dynamically added to __all__ logger = setup_logging() @@ -24,7 +23,12 @@ if TYPE_CHECKING: from .types import registry from .. conversation import Conversation -from .. message import Message +from .. message import Message, Tunables + +class LLMMessage(BaseModel): + role : str = Field(default="") + content : str = Field(default="") + tool_calls : Optional[List[Dict]] = Field(default={}, exclude=True) class Agent(BaseModel, ABC): """ @@ -35,15 +39,8 @@ class Agent(BaseModel, ABC): agent_type: Literal["base"] = "base" _agent_type: ClassVar[str] = agent_type # Add this for registration - # context_size is shared across all subclasses - _context_size: ClassVar[int] = int(defines.max_context * 0.5) - @property - def context_size(self) -> int: - return Agent._context_size - - @context_size.setter - def context_size(self, value: int): - Agent._context_size = value + # Tunables (sets default for new Messages attached to this agent) + tunables: Tunables = Field(default_factory=Tunables) # Agent properties system_prompt: str # Mandatory @@ -51,7 +48,15 @@ class Agent(BaseModel, ABC): context_tokens: int = 0 context: Optional[Context] = Field(default=None, exclude=True) # Avoid circular reference, require as param, and prevent serialization - _content_seed: str = PrivateAttr(default="") + # context_size is shared across all subclasses + _context_size: ClassVar[int] = int(defines.max_context * 0.5) + @property + def context_size(self) -> int: + return Agent._context_size + + @context_size.setter + def context_size(self, value: int): + Agent._context_size = value def set_optimal_context_size(self, llm: Any, model: str, prompt: str, ctx_buffer=2048) -> int: # # Get more accurate token count estimate using tiktoken or similar @@ -114,18 +119,18 @@ class Agent(BaseModel, ABC): """ Prepare message with context information in message.preamble """ - logging.info(f"{self.agent_type} - {inspect.stack()[1].function}") + logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") if not self.context: raise ValueError("Context is not set for this agent.") # Generate RAG content if enabled, based on the content rag_context = "" - if message.enable_rag: + 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): - logging.info(f"RAG: {message.status} - {message.response}") + logger.info(f"RAG: {message.status} - {message.response}") if message.status == "error": yield message return @@ -142,27 +147,16 @@ class Agent(BaseModel, ABC): if rag_context: message.preamble["context"] = rag_context - if self.context.user_resume: + if message.tunables.enable_context and self.context.user_resume: message.preamble["resume"] = self.context.user_resume - - if message.preamble: - preamble_types = [f"<|{p}|>" for p in message.preamble.keys()] - preamble_types_AND = " and ".join(preamble_types) - preamble_types_OR = " or ".join(preamble_types) - message.preamble["rules"] = f"""\ - - Answer the question based on the information provided in the {preamble_types_AND} sections by incorporate it seamlessly and refer to it using natural language instead of mentioning {preamble_types_OR} or quoting it directly. - - If there is no information in these sections, answer based on your knowledge, or use any available tools. - - Avoid phrases like 'According to the {preamble_types[0]}' or similar references to the {preamble_types_OR}. - """ - message.preamble["question"] = "Respond to:" message.system_prompt = self.system_prompt message.status = "done" yield message return - async def process_tool_calls(self, llm: Any, model: str, message: Message, tool_message: Any, messages: List[Any]) -> AsyncGenerator[Message, None]: - logging.info(f"{self.agent_type} - {inspect.stack()[1].function}") + async def process_tool_calls(self, llm: Any, model: str, message: Message, tool_message: Any, messages: List[LLMMessage]) -> AsyncGenerator[Message, None]: + logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") if not self.context: raise ValueError("Context is not set for this agent.") @@ -170,7 +164,6 @@ class Agent(BaseModel, ABC): raise ValueError("tools field not initialized") tool_metadata = message.metadata["tools"] - tool_metadata["messages"] = messages tool_metadata["tool_calls"] = [] message.status = "tooling" @@ -182,7 +175,7 @@ class Agent(BaseModel, ABC): # Yield status update before processing each tool message.response = f"Processing tool {i+1}/{len(tool_message.tool_calls)}: {tool}..." yield message - logging.info(f"LLM - {message.response}") + logger.info(f"LLM - {message.response}") # Process the tool based on its type match tool: @@ -231,17 +224,17 @@ class Agent(BaseModel, ABC): yield message return - message_dict = { - "role": tool_message.get("role", "assistant"), - "content": tool_message.get("content", ""), - "tool_calls": [ { - "function": { - "name": tc["function"]["name"], - "arguments": tc["function"]["arguments"] - } - } for tc in tool_message.tool_calls - ] - } + message_dict = LLMMessage( + role=tool_message.get("role", "assistant"), + content=tool_message.get("content", ""), + tool_calls=[ { + "function": { + "name": tc["function"]["name"], + "arguments": tc["function"]["arguments"] + } + } for tc in tool_message.tool_calls + ] + ) messages.append(message_dict) messages.extend(tool_metadata["tool_calls"]) @@ -262,7 +255,7 @@ class Agent(BaseModel, ABC): # "temperature": 0.5, } ): - # logging.info(f"LLM::Tools: {'done' if response.done else 'processing'} - {response.message}") + # logger.info(f"LLM::Tools: {'done' if response.done else 'processing'} - {response.message}") message.status = "streaming" message.response += response.message.content if not response.done: @@ -281,24 +274,25 @@ class Agent(BaseModel, ABC): return async def generate_llm_response(self, llm: Any, model: str, message: Message) -> AsyncGenerator[Message, None]: - logging.info(f"{self.agent_type} - {inspect.stack()[1].function}") + logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") if not self.context: raise ValueError("Context is not set for this agent.") - messages = [ { "role": "system", "content": message.system_prompt } ] + # Create a pruned down message list based purely on the prompt and responses, + # discarding the full preamble generated by prepare_message + messages: List[LLMMessage] = [ LLMMessage(role="system", content=message.system_prompt) ] messages.extend([ - item for m in self.conversation.messages - for item in [ - {"role": "user", "content": m.prompt.strip()}, - {"role": "assistant", "content": m.response.strip()} - ] + item for m in self.conversation + for item in [ + LLMMessage(role="user", content=m.prompt.strip()), + LLMMessage(role="assistant", content=m.response.strip()) + ] ]) - messages.append({ - "role": "user", - "content": message.context_prompt.strip(), - }) - message.metadata["messages"] = messages + # Only the actual user query is provided with the full context message + messages.append(LLMMessage(role="user", content=message.context_prompt.strip())) + + #message.metadata["messages"] = messages message.metadata["options"]={ "seed": 8911, "num_ctx": self.context_size, @@ -307,7 +301,7 @@ class Agent(BaseModel, ABC): message.metadata["timers"] = {} - use_tools = message.enable_tools and len(self.context.tools) > 0 + use_tools = message.tunables.enable_tools and len(self.context.tools) > 0 message.metadata["tools"] = { "available": llm_tools(self.context.tools), "used": False @@ -319,37 +313,38 @@ class Agent(BaseModel, ABC): message.response = f"Performing tool analysis step 1/2..." yield message - logging.info("Checking for LLM tool usage") - start_time = time.perf_counter() - # Tools are enabled and available, so query the LLM with a short token target to see if it will - # use the tools - tool_metadata["messages"] = [{ "role": "system", "content": self.system_prompt}, {"role": "user", "content": message.prompt}] - response = llm.chat( - model=model, - messages=tool_metadata["messages"], - tools=tool_metadata["available"], - options={ - **message.metadata["options"], - #"num_predict": 1024, # "Low" token limit to cut off after tool call - }, - stream=False # No need to stream the probe - ) - end_time = time.perf_counter() - message.metadata["timers"]["tool_check"] = f"{(end_time - start_time):.4f}" - if not response.message.tool_calls: - logging.info("LLM indicates tools will not be used") - # The LLM will not use tools, so disable use_tools so we can stream the full response - use_tools = False + logger.info("Checking for LLM tool usage") + start_time = time.perf_counter() + # Tools are enabled and available, so query the LLM with a short token target to see if it will + # use the tools + tool_metadata["messages"] = [{ "role": "system", "content": self.system_prompt}, {"role": "user", "content": message.prompt}] + response = llm.chat( + model=model, + messages=tool_metadata["messages"], + tools=tool_metadata["available"], + options={ + **message.metadata["options"], + #"num_predict": 1024, # "Low" token limit to cut off after tool call + }, + stream=False # No need to stream the probe + ) + end_time = time.perf_counter() + message.metadata["timers"]["tool_check"] = f"{(end_time - start_time):.4f}" + if not response.message.tool_calls: + logger.info("LLM indicates tools will not be used") + # The LLM will not use tools, so disable use_tools so we can stream the full response + use_tools = False + else: + tool_metadata["attempted"] = response.message.tool_calls if use_tools: - logging.info("LLM indicates tools will be used") + logger.info("LLM indicates tools will be used") # Tools are enabled and available and the LLM indicated it will use them - tool_metadata["attempted"] = response.message.tool_calls message.response = f"Performing tool analysis step 2/2 (tool use suspected)..." yield message - logging.info(f"Performing LLM call with tools") + logger.info(f"Performing LLM call with tools") start_time = time.perf_counter() response = llm.chat( model=model, @@ -383,13 +378,15 @@ class Agent(BaseModel, ABC): message.status = "done" return - logging.info("LLM indicated tools will be used, and then they weren't") + logger.info("LLM indicated tools will be used, and then they weren't") message.response = response.message.content message.status = "done" yield message return # not use_tools + message.status = "thinking" + message.response = f"Generating response..." yield message # Reset the response for streaming message.response = "" @@ -428,13 +425,13 @@ class Agent(BaseModel, ABC): return async def process_message(self, llm: Any, model: str, message:Message) -> AsyncGenerator[Message, None]: - logging.info(f"{self.agent_type} - {inspect.stack()[1].function}") + logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") if not self.context: raise ValueError("Context is not set for this agent.") if self.context.processing: - logging.info("TODO: Implement delay queing; busy for same agent, otherwise return queue size and estimated wait time") + logger.info("TODO: Implement delay queing; busy for same agent, otherwise return queue size and estimated wait time") message.status = "error" message.response = "Busy processing another request." yield message @@ -460,7 +457,7 @@ class Agent(BaseModel, ABC): yield message async for message in self.generate_llm_response(llm, model, message): - # logging.info(f"LLM: {message.status} - {f'...{message.response[-20:]}' if len(message.response) > 20 else message.response}") + # logger.info(f"LLM: {message.status} - {f'...{message.response[-20:]}' if len(message.response) > 20 else message.response}") if message.status == "error": yield message self.context.processing = False @@ -469,7 +466,7 @@ class Agent(BaseModel, ABC): # Done processing, add message to conversation message.status = "done" - self.conversation.add_message(message) + self.conversation.add(message) self.context.processing = False return diff --git a/src/utils/agents/chat.py b/src/utils/agents/chat.py index 4be43eb..990cdc2 100644 --- a/src/utils/agents/chat.py +++ b/src/utils/agents/chat.py @@ -1,9 +1,30 @@ from __future__ import annotations -from typing import Literal, AsyncGenerator, ClassVar, Optional -import logging +from typing import Literal, AsyncGenerator, ClassVar, Optional, Any +from datetime import datetime +import inspect + from . base import Agent, registry from .. message import Message -import inspect +from .. setup_logging import setup_logging +logger = setup_logging() + +system_message = f""" +Launched on {datetime.now().isoformat()}. + +When answering queries, follow these steps: + +- First analyze the query to determine if real-time information from the tools might be helpful +- Even when <|context|> or <|resume|> is provided, consider whether the tools would provide more current or comprehensive information +- Use the provided tools whenever they would enhance your response, regardless of whether context is also available +- When presenting weather forecasts, include relevant emojis immediately before the corresponding text. For example, for a sunny day, say \"☀️ Sunny\" or if the forecast says there will be \"rain showers, say \"🌧️ Rain showers\". Use this mapping for weather emojis: Sunny: ☀️, Cloudy: ☁️, Rainy: 🌧️, Snowy: ❄️ +- When any combination of <|context|>, <|resume|> and tool outputs are relevant, synthesize information from all sources to provide the most complete answer +- Always prioritize the most up-to-date and relevant information, whether it comes from <|context|>, <|resume|> or tools +- If <|context|> and tool outputs contain conflicting information, prefer the tool outputs as they likely represent more current data +- If there is information in the <|context|> or <|resume|> sections to enhance the answer, incorporate it seamlessly and refer to it as 'the latest information' or 'recent data' instead of mentioning '<|context|>' (etc.) or quoting it directly. +- Avoid phrases like 'According to the <|context|>' or similar references to the <|context|> or <|resume|>. + +Always use tools, <|resume|>, and <|context|> when possible. Be concise, and never make up information. If you do not know the answer, say so. +""" class Chat(Agent): """ @@ -12,5 +33,27 @@ class Chat(Agent): agent_type: Literal["chat"] = "chat" # type: ignore _agent_type: ClassVar[str] = agent_type # Add this for registration + system_prompt: str = system_message + + async def prepare_message(self, message:Message) -> AsyncGenerator[Message, None]: + logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") + if not self.context: + raise ValueError("Context is not set for this agent.") + + async for message in super().prepare_message(message): + if message.status != "done": + yield message + + if message.preamble: + preamble_types = [f"<|{p}|>" for p in message.preamble.keys()] + preamble_types_AND = " and ".join(preamble_types) + preamble_types_OR = " or ".join(preamble_types) + message.preamble["rules"] = f"""\ +- Answer the question based on the information provided in the {preamble_types_AND} sections by incorporate it seamlessly and refer to it using natural language instead of mentioning {preamble_types_OR} or quoting it directly. +- If there is no information in these sections, answer based on your knowledge, or use any available tools. +- Avoid phrases like 'According to the {preamble_types[0]}' or similar references to the {preamble_types_OR}. +""" + message.preamble["question"] = "Respond to:" + # Register the base agent registry.register(Chat._agent_type, Chat) diff --git a/src/utils/agents/fact_check.py b/src/utils/agents/fact_check.py index 066ef5c..409a4f1 100644 --- a/src/utils/agents/fact_check.py +++ b/src/utils/agents/fact_check.py @@ -1,12 +1,32 @@ +from __future__ import annotations from pydantic import model_validator # type: ignore -from typing import Literal, ClassVar, Optional -from .base import Agent, registry +from typing import Literal, ClassVar, Optional, Any, AsyncGenerator, List # NOTE: You must import Optional for late binding to work +from datetime import datetime +import inspect + +from . base import Agent, registry +from .. conversation import Conversation +from .. message import Message +from .. setup_logging import setup_logging +logger = setup_logging() + +system_fact_check = f""" +Launched on {datetime.now().isoformat()}. + +You are a professional resume fact checker. Your task is answer any questions about items identified in the <|discrepancies|>. +The <|discrepancies|> indicate inaccuracies or unsupported claims in the <|generated-resume|> based on content from the <|resume|> and <|context|>. + +When answering queries, follow these steps: +- If there is information in the <|context|> or <|resume|> sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '<|context|>' (etc.) or quoting it directly. +- Avoid phrases like 'According to the <|context|>' or similar references to the <|context|>, <|generated-resume|>, or <|resume|> tags. +""".strip() class FactCheck(Agent): agent_type: Literal["fact_check"] = "fact_check" # type: ignore _agent_type: ClassVar[str] = agent_type # Add this for registration - facts: str = "" + system_prompt:str = system_fact_check + facts: str @model_validator(mode="after") def validate_facts(self): @@ -14,5 +34,36 @@ class FactCheck(Agent): raise ValueError("Facts cannot be empty") return self + async def prepare_message(self, message:Message) -> AsyncGenerator[Message, None]: + logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") + if not self.context: + raise ValueError("Context is not set for this agent.") + + resume_agent = self.context.get_agent("resume") + if not resume_agent: + raise ValueError("resume agent does not exist") + + message.enable_tools = False + + async for message in super().prepare_message(message): + if message.status != "done": + yield message + + message.preamble["generated-resume"] = resume_agent.resume + message.preamble["discrepancies"] = self.facts + + preamble_types = [f"<|{p}|>" for p in message.preamble.keys()] + preamble_types_AND = " and ".join(preamble_types) + preamble_types_OR = " or ".join(preamble_types) + message.preamble["rules"] = f"""\ +- Answer the question based on the information provided in the {preamble_types_AND} sections by incorporate it seamlessly and refer to it using natural language instead of mentioning {preamble_types_OR} or quoting it directly. +- If there is no information in these sections, answer based on your knowledge, or use any available tools. +- Avoid phrases like 'According to the {preamble_types[0]}' or similar references to the {preamble_types_OR}. +""" + message.preamble["question"] = "Respond to:" + + yield message + return + # Register the base agent registry.register(FactCheck._agent_type, FactCheck) diff --git a/src/utils/agents/job_description.py b/src/utils/agents/job_description.py index 671e5f7..4313161 100644 --- a/src/utils/agents/job_description.py +++ b/src/utils/agents/job_description.py @@ -1,15 +1,63 @@ +from __future__ import annotations from pydantic import model_validator # type: ignore -from typing import Literal, ClassVar, Optional -from .base import Agent, registry +from typing import Literal, ClassVar, Optional, Any, AsyncGenerator, List # NOTE: You must import Optional for late binding to work +from datetime import datetime +import inspect + +from . base import Agent, registry from .. conversation import Conversation from .. message import Message -from abc import ABC +from .. setup_logging import setup_logging +logger = setup_logging() + +system_generate_resume = f""" +Launched on {datetime.now().isoformat()}. + +You are a professional resume writer. Your task is to write a concise, polished, and tailored resume for a specific job based only on the individual's <|context|>. + +When answering queries, follow these steps: + +- You must not invent or assume any inforation not explicitly present in the <|context|>. +- Analyze the <|job_description|> to identify skills required for the job. +- Use the <|job_description|> provided to guide the focus, tone, and relevant skills or experience to highlight from the <|context|>. +- Identify and emphasize the experiences, achievements, and responsibilities from the <|context|> that best align with the <|job_description|>. +- Only provide information from <|context|> items if it is relevant to the <|job_description|>. +- Do not use the <|job_description|> skills unless listed in <|context|>. +- Do not include any information unless it is provided in <|context|>. +- Use the <|context|> to create a polished, professional resume. +- Do not list any locations or mailing addresses in the resume. +- If there is information in the <|context|>, <|job_description|>, <|context|>, or <|resume|> sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '<|job_description|>' (etc.) or quoting it directly. +- Avoid phrases like 'According to the <|context|>' or similar references to the <|context|>, <|job_description|>, or <|context|> tags. +- Ensure the langauge is clear, concise, and aligned with industry standards for professional resumes. + +Structure the resume professionally with the following sections where applicable: + +* Name: Use full name +* Professional Summary: A 2-4 sentence overview tailored to the job. +* Skills: A bullet list of key skills derived from the work history and relevant to the job. +* Professional Experience: A detailed list of roles, achievements, and responsibilities from <|context|> that relate to the <|job_description|>. +* Education: Include only if available in the work history. +* Notes: Indicate the initial draft of the resume was generated using the Backstory application. +""".strip() + +system_job_description = f""" +Launched on {datetime.now().isoformat()}. + +You are a hiring and job placing specialist. Your task is to answers about a job description. + +When answering queries, follow these steps: +- Analyze the <|job_description|> to provide insights for the asked question. +- If any financial information is requested, be sure to account for inflation. +- If there is information in the <|context|>, <|job_description|>, <|context|>, or <|resume|> sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '<|job_description|>' (etc.) or quoting it directly. +- Avoid phrases like 'According to the <|context|>' or similar references to the <|context|>, <|job_description|>, <|resume|>, or <|context|> tags. +""".strip() class JobDescription(Agent): agent_type: Literal["job_description"] = "job_description" # type: ignore _agent_type: ClassVar[str] = agent_type # Add this for registration - job_description: str = "" + system_prompt: str = system_generate_resume + job_description: str @model_validator(mode="after") def validate_job_description(self): @@ -17,5 +65,63 @@ class JobDescription(Agent): raise ValueError("Job description cannot be empty") return self + async def prepare_message(self, message:Message) -> AsyncGenerator[Message, None]: + logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") + if not self.context: + raise ValueError("Context is not set for this agent.") + + async for message in super().prepare_message(message): + if message.status != "done": + yield message + + # Always add the job description, user resume, and question + message.preamble["job_description"] = self.job_description + message.preamble["resume"] = self.context.user_resume + + preamble_types = [f"<|{p}|>" for p in message.preamble.keys()] + preamble_types_AND = " and ".join(preamble_types) + preamble_types_OR = " or ".join(preamble_types) + message.preamble["rules"] = f"""\ +- Answer the question based on the information provided in the {preamble_types_AND} sections by incorporating it seamlessly and refer to it using natural language instead of mentioning {preamble_types_OR} or quoting it directly. +- If there is no information in these sections, answer based on your knowledge, or use any available tools. +- Avoid phrases like 'According to the {preamble_types[0]}' or similar references to the {preamble_types_OR}. +""" + + resume_agent = self.context.get_agent(agent_type="resume") + if resume_agent: + message.preamble["question"] = "Respond to:" + else: + message.preamble["question"] = "Generate a resume given the <|resume|> and <|job_description|>." + + yield message + return + + async def process_message(self, llm: Any, model: str, message:Message) -> AsyncGenerator[Message, None]: + logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") + if not self.context: + raise ValueError("Context is not set for this agent.") + + async for message in super().process_message(llm, model, message): + if message.status != "done": + yield message + + resume_agent = self.context.get_agent(agent_type="resume") + if not resume_agent: + # Switch agent from "Create Resume from Job Desription" mode + # to "Answer Questions about Job Description" + self.system_prompt = system_job_description + + # Instantiate the "resume" agent, and seed (or reset) its conversation + # with this message. + resume_agent = self.context.get_or_create_agent(agent_type="resume", resume=message.response) + first_resume_message = message.copy() + first_resume_message.prompt = "Generate a resume for the job description." + resume_agent.conversation.add(first_resume_message) + message.response = "Resume generated." + + # Return the final message + yield message + return + # Register the base agent registry.register(JobDescription._agent_type, JobDescription) diff --git a/src/utils/agents/resume.py b/src/utils/agents/resume.py index a3a9107..d36167e 100644 --- a/src/utils/agents/resume.py +++ b/src/utils/agents/resume.py @@ -1,12 +1,47 @@ +from __future__ import annotations from pydantic import model_validator # type: ignore -from typing import Literal, Optional, ClassVar -from .base import Agent, registry +from typing import Literal, ClassVar, Optional, Any, AsyncGenerator, List # NOTE: You must import Optional for late binding to work +from datetime import datetime +import inspect + +from . base import Agent, registry +from .. message import Message +from .. setup_logging import setup_logging +logger = setup_logging() + +system_fact_check = f""" +Launched on {datetime.now().isoformat()}. + +You are a professional resume fact checker. Your task is to identify any inaccuracies in the <|generated-resume|> based on the individual's <|context|> and <|resume|>. + +If there are inaccuracies, list them in a bullet point format. + +When answering queries, follow these steps: +- Analyze the <|generated-resume|> to identify any discrepancies or inaccuracies which are not supported by the <|context|> and <|resume|>. +- If there is information in the <|context|> or <|resume|> sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '<|context|>' (etc.) or quoting it directly. +- Avoid phrases like 'According to the <|context|>' or similar references to the <|context|>, <|generated-resume|>, or <|resume|> tags. + +Do not generate a revised resume. +""".strip() + +system_resume = f""" +Launched on {datetime.now().isoformat()}. + +You are a hiring and job placing specialist. Your task is to answers about a resume and work history as it relates to a potential job. + +When answering queries, follow these steps: +- Analyze the <|job_description|> and <|generated-resume|> to provide insights for the asked question. +- If any financial information is requested, be sure to account for inflation. +- If there is information in the <|context|>, <|job_description|>, <|generated-resume|>, or <|resume|> sections to enhance the answer, incorporate it seamlessly and refer to it using natural language instead of mentioning '<|job_description|>' (etc.) or quoting it directly. +- Avoid phrases like 'According to the <|context|>' or similar references to the <|context|>, <|job_description|>, <|resume|>, or <|context|> tags. +""".strip() class Resume(Agent): agent_type: Literal["resume"] = "resume" # type: ignore _agent_type: ClassVar[str] = agent_type # Add this for registration - resume: str = "" + system_prompt:str = system_fact_check + resume: str @model_validator(mode="after") def validate_resume(self): @@ -14,13 +49,65 @@ class Resume(Agent): raise ValueError("Resume content cannot be empty") return self - def get_resume(self) -> str: - """Get the resume content.""" - return self.resume + async def prepare_message(self, message:Message) -> AsyncGenerator[Message, None]: + logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") + if not self.context: + raise ValueError("Context is not set for this agent.") + + async for message in super().prepare_message(message): + if message.status != "done": + yield message - def set_resume(self, resume: str) -> None: - """Set the resume content.""" - self.resume = resume + message.preamble["generated-resume"] = self.resume + job_description_agent = self.context.get_agent("job_description") + if not job_description_agent: + raise ValueError("job_description agent does not exist") + + message.preamble["job_description"] = job_description_agent.job_description + + preamble_types = [f"<|{p}|>" for p in message.preamble.keys()] + preamble_types_AND = " and ".join(preamble_types) + preamble_types_OR = " or ".join(preamble_types) + message.preamble["rules"] = f"""\ +- Answer the question based on the information provided in the {preamble_types_AND} sections by incorporate it seamlessly and refer to it using natural language instead of mentioning {preamble_types_OR} or quoting it directly. +- If there is no information in these sections, answer based on your knowledge, or use any available tools. +- Avoid phrases like 'According to the {preamble_types[0]}' or similar references to the {preamble_types_OR}. +""" + fact_check_agent = self.context.get_agent(agent_type="fact_check") + if fact_check_agent: + message.preamble["question"] = "Respond to:" + else: + message.preamble["question"] = f"Fact check the <|generated-resume|> based on the <|resume|>{' and <|context|>' if 'context' in message.preamble else ''}." + + yield message + return + + async def process_message(self, llm: Any, model: str, message:Message) -> AsyncGenerator[Message, None]: + logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") + if not self.context: + raise ValueError("Context is not set for this agent.") + + async for message in super().process_message(llm, model, message): + if message.status != "done": + yield message + + fact_check_agent = self.context.get_agent(agent_type="fact_check") + if not fact_check_agent: + # Switch agent from "Fact Check Generated Resume" mode + # to "Answer Questions about Generated Resume" + self.system_prompt = system_resume + + # Instantiate the "resume" agent, and seed (or reset) its conversation + # with this message. + fact_check_agent = self.context.get_or_create_agent(agent_type="fact_check", facts=message.response) + first_fact_check_message = message.copy() + first_fact_check_message.prompt = "Fact check the generated resume." + fact_check_agent.conversation.add(first_fact_check_message) + message.response = "Resume fact checked." + + # Return the final message + yield message + return # Register the base agent registry.register(Resume._agent_type, Resume) diff --git a/src/utils/context.py b/src/utils/context.py index e88acaa..d6fbca0 100644 --- a/src/utils/context.py +++ b/src/utils/context.py @@ -6,13 +6,12 @@ from typing_extensions import Annotated, Union import numpy as np # type: ignore import logging from uuid import uuid4 -import re -from .message import Message -from .rag import ChromaDBFileWatcher +from . message import Message, Tunables +from . rag import ChromaDBFileWatcher from . import defines from . import tools as Tools -from .agents import AnyAgent +from . agents import AnyAgent logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -30,7 +29,7 @@ class Context(BaseModel): user_resume: Optional[str] = None user_job_description: Optional[str] = None user_facts: Optional[str] = None - tools: List[dict] = Tools.default_tools(Tools.tools) + tools: List[dict] = Tools.enabled_tools(Tools.tools) rags: List[dict] = [] message_history_length: int = 5 # Class managed fields diff --git a/src/utils/conversation.py b/src/utils/conversation.py index 804cf8c..84c63f8 100644 --- a/src/utils/conversation.py +++ b/src/utils/conversation.py @@ -1,22 +1,41 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field, PrivateAttr # type: ignore from typing import List from .message import Message class Conversation(BaseModel): - messages: List[Message] = [] + Conversation_messages: List[Message] = Field(default=[], alias="messages") - def add_message(self, message: Message | List[Message]) -> None: + def __len__(self): + return len(self.Conversation_messages) + + def __iter__(self): + return iter(self.Conversation_messages) + + def reset(self): + self.Conversation_messages = [] + + @property + def messages(self): + """Return a copy of messages to prevent modification of the internal list.""" + raise AttributeError("Cannot directly get messages. Use Conversation.add() or .reset()") + + @messages.setter + def messages(self, value): + """Control how messages can be set, or prevent setting altogether.""" + raise AttributeError("Cannot directly set messages. Use Conversation.add() or .reset()") + + def add(self, message: Message | List[Message]) -> None: """Add a Message(s) to the conversation.""" if isinstance(message, Message): - self.messages.append(message) + self.Conversation_messages.append(message) else: - self.messages.extend(message) + self.Conversation_messages.extend(message) def get_summary(self) -> str: """Return a summary of the conversation.""" - if not self.messages: + if not self.Conversation_messages: return "Conversation is empty." summary = f"Conversation:\n" - for i, message in enumerate(self.messages, 1): + for i, message in enumerate(self.Conversation_messages, 1): summary += f"\nMessage {i}:\n{message.get_summary()}\n" return summary \ No newline at end of file diff --git a/src/utils/message.py b/src/utils/message.py index 6478f8a..26f4926 100644 --- a/src/utils/message.py +++ b/src/utils/message.py @@ -1,14 +1,18 @@ -from pydantic import BaseModel # type: ignore +from pydantic import BaseModel, Field # type: ignore from typing import Dict, List, Optional, Any from datetime import datetime, timezone +class Tunables(BaseModel): + enable_rag : bool = Field(default=True) # Enable RAG collection chromadb matching + enable_tools : bool = Field(default=True) # Enable LLM to use tools + enable_context : bool = Field(default=True) # Add <|context|> field to message + class Message(BaseModel): # Required prompt: str # Query to be answered # Tunables - enable_rag: bool = True - enable_tools: bool = True + tunables: Tunables = Field(default_factory=Tunables) # Generated while processing message status: str = "" # Status of the message @@ -16,14 +20,14 @@ class Message(BaseModel): system_prompt: str = "" # System prompt provided to the LLM context_prompt: str = "" # Full content of the message (preamble + prompt) response: str = "" # LLM response to the preamble + query - metadata: dict[str, Any] = { - "rag": List[dict[str, Any]], + metadata: Dict[str, Any] = Field(default_factory=lambda: { + "rag": [], "eval_count": 0, "eval_duration": 0, "prompt_eval_count": 0, "prompt_eval_duration": 0, "context_size": 0, - } + }) network_packets: int = 0 # Total number of streaming packets network_bytes: int = 0 # Total bytes sent while streaming packets actions: List[str] = [] # Other session modifying actions performed while processing the message