Plumbed cache through agents

Removed some dead frontend code
This commit is contained in:
James Ketr 2025-07-10 15:04:34 -07:00
parent 2fe1f5b181
commit 80b63fe0e1
17 changed files with 145 additions and 675 deletions

View File

@ -1,41 +1,8 @@
import React, { import React from 'react';
useState,
useImperativeHandle,
forwardRef,
useEffect,
useRef,
useCallback,
} from 'react';
import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import SendIcon from '@mui/icons-material/Send';
import CancelIcon from '@mui/icons-material/Cancel';
import { SxProps, Theme } from '@mui/material'; import { SxProps, Theme } from '@mui/material';
import PropagateLoader from 'react-spinners/PropagateLoader';
import { Message } from './Message';
import { DeleteConfirmation } from 'components/DeleteConfirmation';
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
import { BackstoryElementProps } from './BackstoryTab'; import { BackstoryElementProps } from './BackstoryTab';
import { useAuth } from 'hooks/AuthContext'; import { ChatMessage, ChatQuery, ChatMessageMetaData } from 'types/types';
import { StreamingResponse } from 'services/api-client';
import {
ChatMessage,
ChatContext,
ChatSession,
ChatQuery,
ChatMessageUser,
ChatMessageError,
ChatMessageStreaming,
ChatMessageStatus,
ChatMessageMetaData,
} from 'types/types';
import { PaginatedResponse } from 'types/conversion';
import './Conversation.css';
import { useAppState } from 'hooks/GlobalContext';
const defaultMessage: ChatMessage = { const defaultMessage: ChatMessage = {
status: 'done', status: 'done',
@ -47,11 +14,6 @@ const defaultMessage: ChatMessage = {
metadata: null as unknown as ChatMessageMetaData, metadata: null as unknown as ChatMessageMetaData,
}; };
const loadingMessage: ChatMessage = {
...defaultMessage,
content: 'Establishing connection with server...',
};
type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check' | 'persona'; type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check' | 'persona';
interface ConversationHandle { interface ConversationHandle {
@ -77,417 +39,4 @@ interface ConversationProps extends BackstoryElementProps {
onResponse?: ((message: ChatMessage) => void) | undefined; // Event called when a query completes (provides messages) onResponse?: ((message: ChatMessage) => void) | undefined; // Event called when a query completes (provides messages)
} }
const Conversation = forwardRef<ConversationHandle, ConversationProps>(
(props: ConversationProps, ref) => {
const {
actionLabel,
defaultPrompts,
hideDefaultPrompts,
hidePreamble,
messageFilter,
messages,
onResponse,
placeholder,
preamble,
resetLabel,
sx,
} = props;
const { apiClient } = useAuth();
const [processing, setProcessing] = useState<boolean>(false);
const [conversation, setConversation] = useState<ChatMessage[]>([]);
const conversationRef = useRef<ChatMessage[]>([]);
const [filteredConversation, setFilteredConversation] = useState<ChatMessage[]>([]);
const [processingMessage, setProcessingMessage] = useState<ChatMessage | undefined>(undefined);
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | undefined>(undefined);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
const viewableElementRef = useRef<HTMLDivElement>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const stopRef = useRef(false);
const controllerRef = useRef<StreamingResponse>(null);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const { setSnack } = useAppState();
// Keep the ref updated whenever items changes
useEffect(() => {
conversationRef.current = conversation;
}, [conversation]);
// Update the context status
/* Transform the 'Conversation' by filtering via callback, then adding
* preamble and messages based on whether the conversation
* has any elements yet */
useEffect(() => {
let filtered = [];
if (messageFilter === undefined) {
filtered = conversation;
// console.log('No message filter provided. Using all messages.', filtered);
} else {
//console.log('Filtering conversation...')
filtered =
messageFilter(conversation); /* Do not copy conversation or useEffect will loop forever */
//console.log(`${conversation.length - filtered.length} messages filtered out.`);
}
if (filtered.length === 0) {
setFilteredConversation([...(preamble || []), ...(messages || [])]);
} else {
setFilteredConversation([
...(hidePreamble ? [] : preamble || []),
...(messages || []),
...filtered,
]);
}
}, [conversation, setFilteredConversation, messageFilter, preamble, messages, hidePreamble]);
useEffect(() => {
if (chatSession) {
return;
}
const createChatSession = async (): Promise<void> => {
try {
const chatContext: ChatContext = { type: 'general' };
const response: ChatSession = await apiClient.createChatSession(chatContext);
setChatSession(response);
} catch (e) {
console.error(e);
setSnack('Unable to create chat session.', 'error');
}
};
createChatSession();
}, [chatSession, setChatSession, apiClient, setSnack]);
const getChatMessages = useCallback(async () => {
if (!chatSession || !chatSession.id) {
return;
}
try {
const response: PaginatedResponse<ChatMessage> = await apiClient.getChatMessages(
chatSession.id
);
const messages: ChatMessage[] = response.data;
setProcessingMessage(undefined);
setStreamingMessage(undefined);
if (messages.length === 0) {
console.log(`History returned with 0 entries`);
setConversation([]);
setNoInteractions(true);
} else {
console.log(`History returned with ${messages.length} entries:`, messages);
setConversation(messages);
setNoInteractions(false);
}
} catch (error) {
console.error('Unable to obtain chat history', error);
setProcessingMessage({
...defaultMessage,
status: 'error',
content: `Unable to obtain history from server.`,
});
setTimeout(() => {
setProcessingMessage(undefined);
setNoInteractions(true);
}, 3000);
setSnack('Unable to obtain chat history.', 'error');
}
}, [chatSession, apiClient, setSnack]);
// Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => {
if (!chatSession) {
setProcessingMessage(loadingMessage);
return;
}
setProcessingMessage(undefined);
setStreamingMessage(undefined);
setConversation([]);
setNoInteractions(true);
getChatMessages();
}, [chatSession, getChatMessages]);
const handleEnter = (value: string): void => {
const query: ChatQuery = {
prompt: value,
};
processQuery(query);
};
useImperativeHandle(ref, () => ({
submitQuery: (query: ChatQuery): void => {
processQuery(query);
},
fetchHistory: (): void => {
getChatMessages();
},
}));
// const reset = async () => {
// try {
// const response = await fetch(connectionBase + `/api/reset/${sessionId}/${type}`, {
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// 'Accept': 'application/json',
// },
// body: JSON.stringify({ reset: ['history'] })
// });
// if (!response.ok) {
// throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
// }
// if (!response.body) {
// throw new Error('Response body is null');
// }
// setProcessingMessage(undefined);
// setStreamingMessage(undefined);
// setConversation([]);
// setNoInteractions(true);
// } catch (e) {
// setSnack("Error resetting history", "error")
// console.error('Error resetting history:', e);
// }
// };
const cancelQuery = (): void => {
console.log('Stop query');
if (controllerRef.current) {
controllerRef.current.cancel();
}
controllerRef.current = null;
};
const processQuery = (query: ChatQuery): void => {
if (controllerRef.current || !chatSession || !chatSession.id) {
return;
}
setNoInteractions(false);
setConversation([
...conversationRef.current,
{
...defaultMessage,
type: 'text',
content: query.prompt,
},
]);
setProcessing(true);
setProcessingMessage({
...defaultMessage,
content: 'Submitting request...',
});
const chatMessage: ChatMessageUser = {
role: 'user',
sessionId: chatSession.id,
content: query.prompt,
tunables: query.tunables,
status: 'done',
type: 'text',
timestamp: new Date(),
};
controllerRef.current = apiClient.sendMessageStream(chatMessage, {
onMessage: (msg: ChatMessage) => {
console.log('onMessage:', msg);
setConversation([...conversationRef.current, msg]);
setStreamingMessage(undefined);
setProcessingMessage(undefined);
setProcessing(false);
if (onResponse) {
onResponse(msg);
}
},
onError: (error: string | ChatMessageError) => {
console.log('onError:', error);
// Type-guard to determine if this is a ChatMessageBase or a string
if (typeof error === 'object' && error !== null && 'content' in error) {
setProcessingMessage(error as ChatMessage);
setProcessing(false);
controllerRef.current = null;
} else {
setProcessingMessage({
...defaultMessage,
content: error as string,
});
}
},
onStreaming: (chunk: ChatMessageStreaming) => {
console.log('onStreaming:', chunk);
setStreamingMessage({ ...defaultMessage, ...chunk });
},
onStatus: (status: ChatMessageStatus) => {
console.log('onStatus:', status);
},
onComplete: () => {
console.log('onComplete');
controllerRef.current = null;
},
});
};
if (!chatSession) {
return <></>;
}
return (
// <Scrollable
// className={`${className || ""} Conversation`}
// autoscroll
// textFieldRef={viewableElementRef}
// fallbackThreshold={0.5}
// sx={{
// p: 1,
// mt: 0,
// ...sx
// }}
// >
<Box
className="Conversation"
sx={{
flexGrow: 1,
minHeight: 'max-content',
height: 'max-content',
maxHeight: 'max-content',
overflow: 'hidden',
}}
>
<Box sx={{ p: 1, mt: 0, ...sx }}>
{filteredConversation.map((message, index) => (
<Message key={index} {...{ chatSession, sendQuery: processQuery, message }} />
))}
{processingMessage !== undefined && (
<Message
{...{
chatSession,
sendQuery: processQuery,
message: processingMessage,
}}
/>
)}
{streamingMessage !== undefined && (
<Message
{...{
chatSession,
sendQuery: processQuery,
message: streamingMessage,
}}
/>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 1,
}}
>
<PropagateLoader
size="10px"
loading={processing}
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
<Box
className="Query"
sx={{ display: 'flex', flexDirection: 'column', p: 1, flexGrow: 1 }}
>
{placeholder && (
<Box
sx={{
display: 'flex',
flexGrow: 1,
p: 0,
m: 0,
flexDirection: 'column',
}}
ref={viewableElementRef}
>
<BackstoryTextField
ref={backstoryTextRef}
disabled={processing}
onEnter={handleEnter}
placeholder={placeholder}
/>
</Box>
)}
<Box
key="jobActions"
sx={{
display: 'flex',
justifyContent: 'center',
flexDirection: 'row',
}}
>
<DeleteConfirmation
label={resetLabel || 'all data'}
disabled={!chatSession || processingMessage !== undefined || noInteractions}
onDelete={(): void => {
/*reset(); resetAction && resetAction(); */
}}
/>
<Tooltip title={actionLabel || 'Send'}>
<span style={{ display: 'flex', flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
disabled={!chatSession || processingMessage !== undefined}
onClick={(): void => {
processQuery({
prompt:
(backstoryTextRef.current &&
backstoryTextRef.current.getAndResetValue()) ||
'',
});
}}
>
{actionLabel}
<SendIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Cancel">
<span style={{ display: 'flex' }}>
{' '}
{/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton
aria-label="cancel"
onClick={(): void => {
cancelQuery();
}}
sx={{ display: 'flex', margin: 'auto 0px' }}
size="large"
edge="start"
disabled={stopRef.current || !chatSession || processing === false}
>
<CancelIcon />
</IconButton>
</span>
</Tooltip>
</Box>
</Box>
{(noInteractions || !hideDefaultPrompts) &&
defaultPrompts !== undefined &&
defaultPrompts.length !== 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{defaultPrompts.map((element, index) => {
return <Box key={index}>{element}</Box>;
})}
</Box>
)}
<Box sx={{ display: 'flex', flexGrow: 1 }}></Box>
</Box>
</Box>
);
}
);
Conversation.displayName = 'Conversation';
export type { ConversationProps, ConversationHandle }; export type { ConversationProps, ConversationHandle };
export { Conversation };

View File

@ -1,67 +0,0 @@
import React, { forwardRef, useEffect, useState } from 'react';
import useMediaQuery from '@mui/material/useMediaQuery';
import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles';
import MuiMarkdown from 'mui-markdown';
import { BackstoryPageProps } from '../components/BackstoryTab';
import { Conversation, ConversationHandle } from '../components/Conversation';
import { BackstoryQuery } from '../components/BackstoryQuery';
import { CandidateInfo } from 'components/ui/CandidateInfo';
import { useAuth } from 'hooks/AuthContext';
import { Candidate } from 'types/types';
import { useAppState } from 'hooks/GlobalContext';
import * as Types from 'types/types';
const ChatPage = forwardRef<ConversationHandle, BackstoryPageProps>(
(props: BackstoryPageProps, ref) => {
const { setSnack } = useAppState();
const { user } = useAuth();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [questions, setQuestions] = useState<React.ReactElement[]>([]);
const candidate: Candidate | null =
user?.userType === 'candidate' ? (user as Types.Candidate) : null;
// console.log("ChatPage candidate =>", candidate);
useEffect(() => {
if (!candidate) {
return;
}
setQuestions([
<Box sx={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row' }}>
{candidate.questions?.map((q, i: number) => (
<BackstoryQuery key={i} question={q} />
))}
</Box>,
<Box sx={{ p: 1 }}>
<MuiMarkdown>
{`As with all LLM interactions, the results may not be 100% accurate. Please contact **${candidate.fullName}** if you have any questions.`}
</MuiMarkdown>
</Box>,
]);
}, [candidate, isMobile]);
if (!candidate) {
return <></>;
}
return (
<Box>
<CandidateInfo candidate={candidate} action="Chat with Backstory AI about " />
<Conversation
ref={ref}
{...{
multiline: true,
type: 'chat',
placeholder: `What would you like to know about ${candidate?.firstName}?`,
resetLabel: 'chat',
defaultPrompts: questions,
}}
/>
</Box>
);
}
);
export { ChatPage };

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models // Generated TypeScript types from Pydantic models
// Source: src/backend/models.py // Source: src/backend/models.py
// Generated on: 2025-07-01T22:28:07.615325 // Generated on: 2025-07-09T20:57:47.233946
// DO NOT EDIT MANUALLY - This file is auto-generated // DO NOT EDIT MANUALLY - This file is auto-generated
// ============================ // ============================

View File

@ -236,11 +236,6 @@ class Agent(BaseModel, ABC):
# context_size is shared across all subclasses # context_size is shared across all subclasses
_context_size: ClassVar[int] = int(defines.max_context * 0.5) _context_size: ClassVar[int] = int(defines.max_context * 0.5)
conversation: List[ChatMessageUser] = Field(
default_factory=list,
description="Conversation history for this agent, used to maintain context across messages.",
)
@property @property
def context_size(self) -> int: def context_size(self) -> int:
return Agent._context_size return Agent._context_size
@ -519,22 +514,30 @@ Content: {content}
chroma_results = await user.file_watcher.find_similar(query=prompt, top_k=top_k, threshold=threshold) chroma_results = await user.file_watcher.find_similar(query=prompt, top_k=top_k, threshold=threshold)
if not chroma_results: if not chroma_results:
continue continue
query_embedding = np.array(chroma_results["query_embedding"]).flatten() # type: ignore query_embedding = np.array(chroma_results["query_embedding"]).flatten()
umap_2d = []
umap_2d = user.file_watcher.umap_model_2d.transform([query_embedding])[0] # type: ignore umap_3d = []
umap_3d = user.file_watcher.umap_model_3d.transform([query_embedding])[0] # type: ignore if user.file_watcher.umap_model_2d and user.file_watcher.umap_model_3d:
umap_2d = user.file_watcher.umap_model_2d.transform([query_embedding])[0].tolist() # type: ignore
umap_3d = user.file_watcher.umap_model_3d.transform([query_embedding])[0].tolist() # type: ignore
rag_metadata = ChromaDBGetResponse( rag_metadata = ChromaDBGetResponse(
name=rag.name, name=rag.name,
query=prompt, query=prompt,
query_embedding=query_embedding.tolist(), query_embedding=query_embedding.tolist(),
size=user.file_watcher.collection.count(),
ids=chroma_results.get("ids", []), ids=chroma_results.get("ids", []),
distances=chroma_results.get("distances", []),
embeddings=chroma_results.get("embeddings", []), embeddings=chroma_results.get("embeddings", []),
documents=chroma_results.get("documents", []), documents=chroma_results.get("documents", []),
metadatas=chroma_results.get("metadatas", []), metadatas=chroma_results.get("metadatas", []),
umap_embedding_2d=umap_2d.tolist(), umap_embedding_2d=umap_2d,
umap_embedding_3d=umap_3d.tolist(), umap_embedding_3d=umap_3d,
) )
if rag_metadata.query_embedding:
logger.info(f"Len of embedding: {len(rag_metadata.query_embedding)}")
else:
logger.warning(f"No query embedding found in RAG results; {query_embedding}")
results.append(rag_metadata) results.append(rag_metadata)
except Exception as e: except Exception as e:
continue_message = ChatMessageStatus( continue_message = ChatMessageStatus(
@ -552,6 +555,8 @@ Content: {content}
yield final_message yield final_message
return return
# Send a single message to the LLM and return the response
# This is a one-shot generation method that does not maintain conversation history.
async def llm_one_shot( async def llm_one_shot(
self, self,
llm: Any, llm: Any,
@ -636,7 +641,14 @@ Content: {content}
return return
async def generate( async def generate(
self, llm: Any, model: str, session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 self,
llm: Any,
model: str,
session_id: str,
prompt: str,
database: RedisDatabase,
tunables: Optional[Tunables] = None,
temperature=0.7,
) -> AsyncGenerator[ApiMessage, None]: ) -> AsyncGenerator[ApiMessage, None]:
if not self.user: if not self.user:
error_message = ChatMessageError(session_id=session_id, content="No user set for chat generation.") error_message = ChatMessageError(session_id=session_id, content="No user set for chat generation.")
@ -648,11 +660,26 @@ Content: {content}
content=prompt, content=prompt,
) )
await database.add_chat_message(session_id, user_message.model_dump())
logger.info(f"💬 User message saved to database for session {session_id}")
# 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=self.system_prompt)]
# Add the conversation history to the messages
messages.extend(
[
LLMMessage(role=m["role"], content=m["content"])
for m in await database.get_recent_chat_messages(session_id=session_id)
]
)
self.user.metrics.generate_count.labels(agent=self.agent_type).inc() self.user.metrics.generate_count.labels(agent=self.agent_type).inc()
with self.user.metrics.generate_duration.labels(agent=self.agent_type).time(): with self.user.metrics.generate_duration.labels(agent=self.agent_type).time():
context = None context = None
rag_message: ChatMessageRagSearch | None = None rag_message: ChatMessageRagSearch | None = None
if self.user: if self.user:
logger.info("Generating RAG results")
message = None message = None
async for message in self.generate_rag_results(session_id=session_id, prompt=prompt): async for message in self.generate_rag_results(session_id=session_id, prompt=prompt):
if message.status == ApiStatusType.ERROR: if message.status == ApiStatusType.ERROR:
@ -668,16 +695,6 @@ Content: {content}
rag_message = message rag_message = message
context = self.get_rag_context(rag_message) context = self.get_rag_context(rag_message)
# 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=self.system_prompt)]
# Add the conversation history to the messages
messages.extend(
[
LLMMessage(role="user" if isinstance(m, ChatMessageUser) else "assistant", content=m.content)
for m in self.conversation
]
)
# Add the RAG context to the messages if available # Add the RAG context to the messages if available
if context: if context:
messages.append( messages.append(
@ -804,7 +821,7 @@ Content: {content}
num_ctx=self.context_size, num_ctx=self.context_size,
temperature=temperature, temperature=temperature,
) )
logger.info(f"Message options: {options.model_dump(exclude_unset=True)}") logger.info(f"Message options: {options.model_dump(exclude_unset=True)} with {len(messages)} messages")
content = "" content = ""
start_time = time.perf_counter() start_time = time.perf_counter()
response = None response = None
@ -851,6 +868,7 @@ Content: {content}
prompt_eval_count=response.usage.prompt_eval_count, prompt_eval_count=response.usage.prompt_eval_count,
prompt_eval_duration=response.usage.prompt_eval_duration, prompt_eval_duration=response.usage.prompt_eval_duration,
rag_results=rag_message.content if rag_message else [], rag_results=rag_message.content if rag_message else [],
llm_history=messages,
timers={ timers={
"llm_streamed": end_time - start_time, "llm_streamed": end_time - start_time,
"llm_with_tools": 0, # Placeholder for tool processing time "llm_with_tools": 0, # Placeholder for tool processing time
@ -858,9 +876,10 @@ Content: {content}
), ),
) )
await database.add_chat_message(session_id, chat_message.model_dump())
logger.info(f"🤖 Assistent response saved to database for session {session_id}")
# Add the user and chat messages to the conversation # Add the user and chat messages to the conversation
self.conversation.append(user_message)
self.conversation.append(chat_message)
yield chat_message yield chat_message
return return

View File

@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal, AsyncGenerator, ClassVar, Optional, Any from typing import Literal, AsyncGenerator, ClassVar, Optional, Any
from pydantic import Field
from database.core import RedisDatabase
from .base import Agent, agent_registry from .base import Agent, agent_registry
from logger import logger from logger import logger
from .registry import agent_registry from models import ApiMessage, Tunables, ApiStatusType, LLMMessage
from models import ApiMessage, Tunables, ApiStatusType
system_message = """ system_message = """
@ -16,7 +19,7 @@ When answering queries, follow these steps:
- If there is information in the <|context|> section 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. - If there is information in the <|context|> section 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|>. - Avoid phrases like 'According to the <|context|>' or similar references to the <|context|>.
Always <|context|> when possible. Be concise, and never make up information. If you do not know the answer, say so. Always use <|context|> when possible. Be concise, and never make up information. If you do not know the answer, say so.
Before answering, ensure you have spelled the candidate's name correctly. Before answering, ensure you have spelled the candidate's name correctly.
""" """
@ -31,9 +34,17 @@ class CandidateChat(Agent):
_agent_type: ClassVar[str] = agent_type # Add this for registration _agent_type: ClassVar[str] = agent_type # Add this for registration
system_prompt: str = system_message system_prompt: str = system_message
sessions: dict[str, list[LLMMessage]] = Field(default_factory=dict)
async def generate( async def generate(
self, llm: Any, model: str, session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 self,
llm: Any,
model: str,
session_id: str,
prompt: str,
database: RedisDatabase,
tunables: Optional[Tunables] = None,
temperature=0.7,
) -> AsyncGenerator[ApiMessage, None]: ) -> AsyncGenerator[ApiMessage, None]:
user = self.user user = self.user
if not user: if not user:
@ -51,9 +62,17 @@ Use that spelling instead of any spelling you may find in the <|context|>.
{system_message} {system_message}
""" """
if session_id not in self.sessions:
self.sessions[session_id] = [LLMMessage(role="user", content=prompt)]
async for message in super().generate( async for message in super().generate(
llm=llm, model=model, session_id=session_id, prompt=prompt, temperature=temperature, tunables=tunables llm=llm,
model=model,
session_id=session_id,
prompt=prompt,
database=database,
temperature=temperature,
tunables=tunables,
): ):
if message.status == ApiStatusType.ERROR: if message.status == ApiStatusType.ERROR:
yield message yield message

View File

@ -1,86 +0,0 @@
from __future__ import annotations
from typing import Literal, ClassVar
from datetime import datetime
from .base import Agent, agent_registry
from .registry import agent_registry
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|>.
CRITICAL INSTRUCTIONS FOR IMAGE GENERATION:
1. When the user requests to generate an image, inject the following into the response: <GenerateImage prompt="USER-PROMPT"/>. Do this when users request images, drawings, or visual content.
3. MANDATORY: You must respond with EXACTLY this format: <GenerateImage prompt="{{USER-PROMPT}}"/>
4. FORBIDDEN: DO NOT use markdown image syntax ![](url)
5. FORBIDDEN: DO NOT create fake URLs or file paths
6. FORBIDDEN: DO NOT use any other image embedding format
CORRECT EXAMPLE:
User: "Draw a cat"
Your response: "<GenerateImage prompt='Draw a cat'/>"
WRONG EXAMPLES (DO NOT DO THIS):
- ![](https://example.com/...)
- ![Cat image](any_url)
- <img src="...">
The <GenerateImage prompt="{{USER-PROMPT}}"/> format is the ONLY way to display images in this system.
DO NOT make up a URL for an image or provide markdown syntax for embedding an image. Only use <GenerateImage prompt="{{USER-PROMPT}}".
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):
"""
Chat Agent
"""
agent_type: Literal["general"] = "general" # 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:
# excluded = {}
# preamble_types = [
# f"<|{p}|>" for p in message.preamble.keys() if p not in excluded
# ]
# 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
agent_registry.register(Chat._agent_type, Chat)

View File

@ -9,9 +9,10 @@ from typing import (
) # NOTE: You must import Optional for late binding to work ) # NOTE: You must import Optional for late binding to work
import random import random
import time import time
import time
import os import os
from database.core import RedisDatabase
from .base import Agent, agent_registry from .base import Agent, agent_registry
from models import ( from models import (
ApiActivityType, ApiActivityType,
@ -40,7 +41,14 @@ class ImageGenerator(Agent):
system_prompt: str = "" # No system prompt is used system_prompt: str = "" # No system prompt is used
async def generate( async def generate(
self, llm: Any, model: str, session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 self,
llm: Any,
model: str,
session_id: str,
prompt: str,
database: RedisDatabase,
tunables: Optional[Tunables] = None,
temperature=0.7,
) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming, None]: ) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming, None]:
if not self.user: if not self.user:
logger.error("User is not set for ImageGenerator agent.") logger.error("User is not set for ImageGenerator agent.")

View File

@ -17,6 +17,8 @@ import json
import time import time
import os import os
from database.core import RedisDatabase
from .base import Agent, agent_registry from .base import Agent, agent_registry
from models import ( from models import (
ApiActivityType, ApiActivityType,
@ -303,7 +305,14 @@ class GeneratePersona(Agent):
self.full_name = f"{self.first_name} {self.last_name}" self.full_name = f"{self.first_name} {self.last_name}"
async def generate( async def generate(
self, llm: Any, model: str, session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 self,
llm: Any,
model: str,
session_id: str,
prompt: str,
database: RedisDatabase,
tunables: Optional[Tunables] = None,
temperature=0.7,
) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming, None]: ) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming, None]:
self.randomize() self.randomize()

View File

@ -11,6 +11,8 @@ from typing import (
import inspect import inspect
import json import json
from database.core import RedisDatabase
from .base import Agent, agent_registry from .base import Agent, agent_registry
from models import ( from models import (
ApiActivityType, ApiActivityType,
@ -153,7 +155,14 @@ Avoid vague categorizations and be precise about whether skills are explicitly r
return display return display
async def generate( async def generate(
self, llm: Any, model: str, session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 self,
llm: Any,
model: str,
session_id: str,
prompt: str,
database: RedisDatabase,
tunables: Optional[Tunables] = None,
temperature=0.7,
) -> AsyncGenerator[ApiMessage, None]: ) -> AsyncGenerator[ApiMessage, None]:
if not self.user: if not self.user:
error_message = ChatMessageError(session_id=session_id, content="User is not set for this agent.") error_message = ChatMessageError(session_id=session_id, content="User is not set for this agent.")

View File

@ -1,23 +1,31 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal, AsyncGenerator, ClassVar, Optional, Any from typing import Literal, AsyncGenerator, ClassVar, Optional, Any
from database.core import RedisDatabase
from .base import Agent, agent_registry from .base import Agent, agent_registry
from logger import logger from logger import logger
from .registry import agent_registry from models import ApiMessage, ApiStatusType, ChatMessageError, ChatMessageRagSearch, Tunables
from models import ApiMessage, ApiStatusType, ChatMessageError, ChatMessageRagSearch, ApiStatusType, Tunables
class Chat(Agent): class RagSearchChat(Agent):
""" """
Chat Agent RagSearchChat Agent
""" """
agent_type: Literal["rag_search"] = "rag_search" # type: ignore agent_type: Literal["rag_search"] = "rag_search" # type: ignore
_agent_type: ClassVar[str] = agent_type # Add this for registration _agent_type: ClassVar[str] = agent_type # Add this for registration
async def generate( async def generate(
self, llm: Any, model: str, session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 self,
llm: Any,
model: str,
session_id: str,
prompt: str,
database: RedisDatabase,
tunables: Optional[Tunables] = None,
temperature=0.7,
) -> AsyncGenerator[ApiMessage, None]: ) -> AsyncGenerator[ApiMessage, None]:
""" """
Generate a response based on the user message and the provided LLM. Generate a response based on the user message and the provided LLM.
@ -53,4 +61,4 @@ class Chat(Agent):
# Register the base agent # Register the base agent
agent_registry.register(Chat._agent_type, Chat) agent_registry.register(RagSearchChat._agent_type, RagSearchChat)

View File

@ -9,6 +9,8 @@ from typing import (
) # NOTE: You must import Optional for late binding to work ) # NOTE: You must import Optional for late binding to work
import json import json
from database.core import RedisDatabase
from .base import Agent, agent_registry from .base import Agent, agent_registry
from models import ( from models import (
ApiMessage, ApiMessage,
@ -106,7 +108,14 @@ JSON RESPONSE:"""
return system_prompt, prompt return system_prompt, prompt
async def generate( async def generate(
self, llm: Any, model: str, session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 self,
llm: Any,
model: str,
session_id: str,
prompt: str,
database: RedisDatabase,
tunables: Optional[Tunables] = None,
temperature=0.7,
) -> AsyncGenerator[ApiMessage, None]: ) -> AsyncGenerator[ApiMessage, None]:
if not self.user: if not self.user:
error_message = ChatMessageError( error_message = ChatMessageError(

View File

@ -1084,11 +1084,6 @@ class ChatMessageUser(ApiMessage):
class ChatMessage(ChatMessageUser): class ChatMessage(ChatMessageUser):
role: ChatSenderType = ChatSenderType.ASSISTANT role: ChatSenderType = ChatSenderType.ASSISTANT
metadata: ChatMessageMetaData = Field(default=ChatMessageMetaData()) metadata: ChatMessageMetaData = Field(default=ChatMessageMetaData())
# attachments: Optional[List[Attachment]] = None
# reactions: Optional[List[MessageReaction]] = None
# is_edited: bool = Field(False, alias=str("isEdited"))
# edit_history: Optional[List[EditHistory]] = Field(default=None, alias=str("editHistory"))
class ChatMessageSkillAssessment(ChatMessageUser): class ChatMessageSkillAssessment(ChatMessageUser):
role: ChatSenderType = ChatSenderType.ASSISTANT role: ChatSenderType = ChatSenderType.ASSISTANT
@ -1145,7 +1140,6 @@ class ChatSession(BaseModel):
last_activity: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("lastActivity")) last_activity: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("lastActivity"))
title: Optional[str] = None title: Optional[str] = None
context: ChatContext context: ChatContext
# messages: Optional[List[ChatMessage]] = None
is_archived: bool = Field(False, alias=str("isArchived")) is_archived: bool = Field(False, alias=str("isArchived"))
system_prompt: Optional[str] = Field(default=None, alias=str("systemPrompt")) system_prompt: Optional[str] = Field(default=None, alias=str("systemPrompt"))
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)

View File

@ -299,7 +299,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
n_components=2, n_components=2,
random_state=8911, random_state=8911,
metric="cosine", metric="cosine",
n_neighbors=30, n_neighbors=round(min(30, len(self._umap_collection.embeddings) * 0.5)),
min_dist=0.1, min_dist=0.1,
) )
self._umap_embedding_2d = self._umap_model_2d.fit_transform(vectors) # type: ignore self._umap_embedding_2d = self._umap_model_2d.fit_transform(vectors) # type: ignore
@ -312,7 +312,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
n_components=3, n_components=3,
random_state=8911, random_state=8911,
metric="cosine", metric="cosine",
n_neighbors=30, n_neighbors=round(min(30, len(self._umap_collection.embeddings) * 0.5)),
min_dist=0.01, min_dist=0.01,
) )
self._umap_embedding_3d = self._umap_model_3d.fit_transform(vectors) # type: ignore self._umap_embedding_3d = self._umap_model_3d.fit_transform(vectors) # type: ignore

View File

@ -113,6 +113,7 @@ async def create_candidate_ai(
model=defines.model, model=defines.model,
session_id=user_message.session_id, session_id=user_message.session_id,
prompt=user_message.content, prompt=user_message.content,
database=database,
): ):
if isinstance(generated_message, ChatMessageError): if isinstance(generated_message, ChatMessageError):
error_message: ChatMessageError = generated_message error_message: ChatMessageError = generated_message
@ -1375,6 +1376,7 @@ async def post_candidate_rag_search(
model=defines.model, model=defines.model,
session_id=user_message.session_id, session_id=user_message.session_id,
prompt=user_message.content, prompt=user_message.content,
database=database,
): ):
rag_message = generated_message rag_message = generated_message
@ -1387,6 +1389,7 @@ async def post_candidate_rag_search(
return create_success_response(final_message.content[0].model_dump(by_alias=True)) return create_success_response(final_message.content[0].model_dump(by_alias=True))
except Exception as e: except Exception as e:
logger.error(backstory_traceback.format_exc())
logger.error(f"❌ Get candidate chat summary error: {e}") logger.error(f"❌ Get candidate chat summary error: {e}")
return JSONResponse(status_code=500, content=create_error_response("SUMMARY_ERROR", str(e))) return JSONResponse(status_code=500, content=create_error_response("SUMMARY_ERROR", str(e)))
@ -1622,6 +1625,7 @@ async def get_candidate_skill_match(
model=defines.model, model=defines.model,
session_id=MOCK_UUID, session_id=MOCK_UUID,
prompt=skill, prompt=skill,
database=database,
): ):
if generated_message.status == ApiStatusType.ERROR: if generated_message.status == ApiStatusType.ERROR:
if isinstance(generated_message, ChatMessageError): if isinstance(generated_message, ChatMessageError):
@ -2077,9 +2081,6 @@ async def get_candidate_chat_sessions(
session = ChatSession.model_validate(session_data) session = ChatSession.model_validate(session_data)
if session.user_id != current_user.id: if session.user_id != current_user.id:
# User can only access their own sessions # User can only access their own sessions
logger.info(
f"🔗 Skipping session {session.id} - not owned by user {current_user.id} (created by {session.user_id})"
)
continue continue
# Check if this session is related to the candidate # Check if this session is related to the candidate
context = session.context context = session.context

View File

@ -171,7 +171,7 @@ async def post_chat_session_message_stream(
# Get candidate info if this chat is about a specific candidate # Get candidate info if this chat is about a specific candidate
if candidate_info: if candidate_info:
logger.info( logger.info(
f"🔗 Chat session {user_message.session_id} about candidate {candidate_info['name']} accessed by user {current_user.id}" f"🔗 Chat session {user_message.session_id} about {candidate_info['name']} accessed by user {current_user.id}"
) )
else: else:
logger.info( logger.info(
@ -208,19 +208,15 @@ async def post_chat_session_message_stream(
content=create_error_response("AGENT_NOT_FOUND", "No agent found for this chat type"), content=create_error_response("AGENT_NOT_FOUND", "No agent found for this chat type"),
) )
# Persist user message to database
await database.add_chat_message(user_message.session_id, user_message.model_dump())
logger.info(f"💬 User message saved to database for session {user_message.session_id}")
# Update session last activity # Update session last activity
chat_session_data["lastActivity"] = datetime.now(UTC).isoformat() chat_session.last_activity = datetime.now(UTC)
await database.set_chat_session(user_message.session_id, chat_session_data) await database.set_chat_session(user_message.session_id, chat_session.model_dump())
return await stream_agent_response( return await stream_agent_response(
chat_agent=chat_agent, chat_agent=chat_agent,
user_message=user_message, user_message=user_message,
database=database, database=database,
chat_session_data=chat_session_data, chat_session=chat_session,
) )
except Exception: except Exception:

View File

@ -136,6 +136,7 @@ async def create_job_from_content(database: RedisDatabase, current_user: Candida
model=defines.model, model=defines.model,
session_id=MOCK_UUID, session_id=MOCK_UUID,
prompt=markdown_message.content, prompt=markdown_message.content,
database=database,
): ):
if message.status != ApiStatusType.DONE: if message.status != ApiStatusType.DONE:
yield message yield message

View File

@ -12,7 +12,7 @@ from fastapi.responses import StreamingResponse
import defines import defines
from logger import logger from logger import logger
from models import DocumentType from models import ChatSession, DocumentType
from models import Job, ChatMessage, ApiStatusType from models import Job, ChatMessage, ApiStatusType
import utils.llm_proxy as llm_manager import utils.llm_proxy as llm_manager
@ -65,7 +65,9 @@ def filter_and_paginate(
return paginated_items, total return paginated_items, total
async def stream_agent_response(chat_agent, user_message, database, chat_session_data=None) -> StreamingResponse: async def stream_agent_response(
chat_agent, user_message, database, chat_session: Optional[ChatSession] = None
) -> StreamingResponse:
"""Stream agent response with proper formatting""" """Stream agent response with proper formatting"""
async def message_stream_generator(): async def message_stream_generator():
@ -79,6 +81,7 @@ async def stream_agent_response(chat_agent, user_message, database, chat_session
model=defines.model, model=defines.model,
session_id=user_message.session_id, session_id=user_message.session_id,
prompt=user_message.content, prompt=user_message.content,
database=database,
): ):
if generated_message.status == ApiStatusType.ERROR: if generated_message.status == ApiStatusType.ERROR:
logger.error(f"❌ AI generation error: {generated_message.content}") logger.error(f"❌ AI generation error: {generated_message.content}")
@ -109,13 +112,10 @@ async def stream_agent_response(chat_agent, user_message, database, chat_session
# After streaming is complete, persist the final AI message to database # After streaming is complete, persist the final AI message to database
if final_message and final_message.status == ApiStatusType.DONE: if final_message and final_message.status == ApiStatusType.DONE:
try: try:
if database and chat_session_data: if database and chat_session:
await database.add_chat_message(final_message.session_id, final_message.model_dump())
logger.info(f"🤖 Message saved to database for session {final_message.session_id}")
# Update session last activity again # Update session last activity again
chat_session_data["lastActivity"] = datetime.now(UTC).isoformat() chat_session.last_activity = datetime.now(UTC)
await database.set_chat_session(final_message.session_id, chat_session_data) await database.set_chat_session(final_message.session_id, chat_session.model_dump())
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to save message to database: {e}") logger.error(f"❌ Failed to save message to database: {e}")
@ -271,6 +271,7 @@ async def create_job_from_content(database, current_user, content: str):
model=defines.model, model=defines.model,
session_id=MOCK_UUID, session_id=MOCK_UUID,
prompt=markdown_message.content, prompt=markdown_message.content,
database=database,
): ):
if message.status != ApiStatusType.DONE: if message.status != ApiStatusType.DONE:
yield message yield message