Added chat/resume

This commit is contained in:
James Ketr 2025-07-16 16:12:10 -07:00
parent 621bf46a39
commit 574d040492
20 changed files with 498 additions and 1082 deletions

View File

@ -594,68 +594,68 @@ ENV PATH=/opt/backstory:$PATH
ENTRYPOINT [ "/entrypoint.sh" ] ENTRYPOINT [ "/entrypoint.sh" ]
FROM ubuntu:24.04 AS ollama-ov-server # FROM ubuntu:24.04 AS ollama-ov-server
SHELL ["/bin/bash", "-c"] # SHELL ["/bin/bash", "-c"]
RUN apt-get update && apt install -y software-properties-common libtbb-dev # RUN apt-get update && apt install -y software-properties-common libtbb-dev
RUN add-apt-repository ppa:deadsnakes/ppa \ # RUN add-apt-repository ppa:deadsnakes/ppa \
&& apt-get update \ # && apt-get update \
&& apt-get install -y python3.10 net-tools # && apt-get install -y python3.10 net-tools
RUN ln -sf /usr/bin/python3.10 /usr/bin/python3 # RUN ln -sf /usr/bin/python3.10 /usr/bin/python3
RUN apt-get install -y ca-certificates git wget curl gcc g++ \ # RUN apt-get install -y ca-certificates git wget curl gcc g++ \
&& apt-get clean \ # && apt-get clean \
&& rm -rf /var/lib/apt/lists/* # && rm -rf /var/lib/apt/lists/*
WORKDIR /home/ollama_ov_server # WORKDIR /home/ollama_ov_server
ARG GOVERSION=1.24.1 # ARG GOVERSION=1.24.1
RUN curl -fsSL https://golang.org/dl/go${GOVERSION}.linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local # RUN curl -fsSL https://golang.org/dl/go${GOVERSION}.linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local
ENV PATH=/usr/local/go/bin:$PATH # ENV PATH=/usr/local/go/bin:$PATH
RUN wget https://storage.openvinotoolkit.org/repositories/openvino_genai/packages/nightly/2025.2.0.0.dev20250513/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64.tar.gz # RUN wget https://storage.openvinotoolkit.org/repositories/openvino_genai/packages/nightly/2025.2.0.0.dev20250513/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64.tar.gz
RUN tar -xzf openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64.tar.gz # RUN tar -xzf openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64.tar.gz
ENV GENAI_DIR=/home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64 # ENV GENAI_DIR=/home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64
RUN source /home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64/setupvars.sh # RUN source /home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64/setupvars.sh
ENV CGO_ENABLED=1 # ENV CGO_ENABLED=1
ENV GODEBUG=cgocheck=0 # ENV GODEBUG=cgocheck=0
ENV CGO_LDFLAGS=-L$GENAI_DIR/runtime/lib/intel64 # ENV CGO_LDFLAGS=-L$GENAI_DIR/runtime/lib/intel64
ENV CGO_CFLAGS=-I$GENAI_DIR/runtime/include # ENV CGO_CFLAGS=-I$GENAI_DIR/runtime/include
WORKDIR /home/ollama_ov_server # WORKDIR /home/ollama_ov_server
RUN git clone https://github.com/openvinotoolkit/openvino_contrib.git # RUN git clone https://github.com/openvinotoolkit/openvino_contrib.git
WORKDIR /home/ollama_ov_server/openvino_contrib/modules/ollama_openvino # WORKDIR /home/ollama_ov_server/openvino_contrib/modules/ollama_openvino
RUN go build -o /usr/bin/ollama . # RUN go build -o /usr/bin/ollama .
ENV OLLAMA_HOST=0.0.0.0:11434 # ENV OLLAMA_HOST=0.0.0.0:11434
EXPOSE 11434 # EXPOSE 11434
RUN apt-get update \ # RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \ # && DEBIAN_FRONTEND=noninteractive apt-get install -y \
pip \ # pip \
&& apt-get clean \ # && apt-get clean \
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} # && rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
RUN pip install huggingface_hub modelscope # RUN pip install huggingface_hub modelscope
#ENV model=Qwen3-4B-int4-ov # #ENV model=Qwen3-4B-int4-ov
#ENV model=Qwen3-8B-int4-ov -- didn't work # #ENV model=Qwen3-8B-int4-ov -- didn't work
#RUN huggingface-cli download OpenVINO/${model} # #RUN huggingface-cli download OpenVINO/${model}
#RUN modelscope download --model OpenVINO/${model} --local_dir ./${model} # #RUN modelscope download --model OpenVINO/${model} --local_dir ./${model}
#RUN tar -zcvf /root/.ollama/models/${model}.tar.gz /root/.cache/hub/models--OpenVINO--${model} # #RUN tar -zcvf /root/.ollama/models/${model}.tar.gz /root/.cache/hub/models--OpenVINO--${model}
#RUN { \ # #RUN { \
# echo "FROM ${model}.tar.gz" ; \ # # echo "FROM ${model}.tar.gz" ; \
# echo "ModelType 'OpenVINO'" ; \ # # echo "ModelType 'OpenVINO'" ; \
#} > /root/.ollama/models/Modelfile # #} > /root/.ollama/models/Modelfile
# # #
#RUN /bin/bash -c "source /home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64/setupvars.sh && /usr/bin/ollama create ${model}:v1 -f /root/.ollama/models/Modelfile" # #RUN /bin/bash -c "source /home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64/setupvars.sh && /usr/bin/ollama create ${model}:v1 -f /root/.ollama/models/Modelfile"
ENTRYPOINT ["/bin/bash", "-c", "source /home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64/setupvars.sh && /usr/bin/ollama serve"] # ENTRYPOINT ["/bin/bash", "-c", "source /home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64/setupvars.sh && /usr/bin/ollama serve"]
FROM llm-base AS vllm FROM llm-base AS vllm

View File

@ -188,27 +188,27 @@ services:
- CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN) - CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN)
- CAP_SYS_PTRACE # PTRACE_MODE_READ_REALCREDS ptrace access mode check - CAP_SYS_PTRACE # PTRACE_MODE_READ_REALCREDS ptrace access mode check
ollama-ov-server: # ollama-ov-server:
build: # build:
context: . # context: .
dockerfile: Dockerfile # dockerfile: Dockerfile
target: ollama-ov-server # target: ollama-ov-server
container_name: ollama-ov-server # container_name: ollama-ov-server
restart: "no" # restart: "no"
env_file: # env_file:
- .env # - .env
environment: # environment:
- OLLAMA_HOST=0.0.0.0 # - OLLAMA_HOST=0.0.0.0
- ONEAPI_DEVICE_SELECTOR=level_zero:0 # - ONEAPI_DEVICE_SELECTOR=level_zero:0
devices: # devices:
- /dev/dri:/dev/dri # - /dev/dri:/dev/dri
ports: # ports:
- 11435:11434 # ollama serve port # - 11435:11434 # ollama serve port
networks: # networks:
- internal # - internal
volumes: # volumes:
- ./cache:/root/.cache # Cache hub models and neo_compiler_cache # - ./cache:/root/.cache # Cache hub models and neo_compiler_cache
- ./ollama:/root/.ollama # Cache the ollama models # - ./ollama:/root/.ollama # Cache the ollama models
vllm: vllm:
build: build:

View File

@ -7,3 +7,15 @@
.d2h-file-header { .d2h-file-header {
display: none; display: none;
} }
.d2h-code-line {
display: flex;
flex-direction: row;
max-width: 100%;
}
.d2h-code-line > span {
display: inline-flex;
max-width: 100%;
white-space: wrap;
}

View File

@ -4,21 +4,42 @@ import { Box, SxProps } from '@mui/material';
import { Diff2HtmlUI } from 'diff2html/lib/ui/js/diff2html-ui'; import { Diff2HtmlUI } from 'diff2html/lib/ui/js/diff2html-ui';
import 'diff2html/bundles/css/diff2html.min.css'; import 'diff2html/bundles/css/diff2html.min.css';
import './DiffViewer.css'; import './DiffViewer.css';
// import { Scrollable } from './Scrollable';
interface DiffFile {
content: string;
name: string;
}
interface DiffViewerProps { interface DiffViewerProps {
original: string; original: DiffFile;
content: string; modified: DiffFile;
changeLog: string;
sx?: SxProps; sx?: SxProps;
outputFormat?: 'line-by-line' | 'side-by-side'; outputFormat?: 'line-by-line' | 'side-by-side';
drawFileList?: boolean; drawFileList?: boolean;
} }
const DiffViewer: React.FC<DiffViewerProps> = (props: DiffViewerProps) => { const DiffViewer: React.FC<DiffViewerProps> = (props: DiffViewerProps) => {
const { original, content, sx, outputFormat = 'line-by-line', drawFileList = false } = props; const {
original,
modified,
sx,
outputFormat = 'line-by-line',
changeLog,
drawFileList = false,
} = props;
const diffRef = useRef<HTMLDivElement>(null); const diffRef = useRef<HTMLDivElement>(null);
const diffString = createPatch('Resume', original || '', content || '', 'original', 'modified', { const diffString = createPatch(
'Resume',
original.content || '',
modified.content || '',
original.name,
modified.name,
{
context: 5, context: 5,
}); }
);
useEffect(() => { useEffect(() => {
if (diffRef.current && diffString) { if (diffRef.current && diffString) {
@ -29,7 +50,7 @@ const DiffViewer: React.FC<DiffViewerProps> = (props: DiffViewerProps) => {
const diff2htmlUi = new Diff2HtmlUI(diffRef.current, diffString, { const diff2htmlUi = new Diff2HtmlUI(diffRef.current, diffString, {
drawFileList: false, drawFileList: false,
matching: 'lines', matching: 'lines',
outputFormat: 'side-by-side', outputFormat,
synchronisedScroll: true, synchronisedScroll: true,
highlight: true, highlight: true,
fileContentToggle: false, fileContentToggle: false,
@ -43,18 +64,34 @@ const DiffViewer: React.FC<DiffViewerProps> = (props: DiffViewerProps) => {
}, [diffString, outputFormat, drawFileList]); }, [diffString, outputFormat, drawFileList]);
return ( return (
<Box
sx={{
position: 'relative',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflow: 'auto' /* Scroll if content overflows */,
p: 0,
gap: 1,
...sx,
}}
>
<Box sx={{ display: 'flex', p: 1 }}>{changeLog}</Box>
<Box <Box
ref={diffRef} ref={diffRef}
className="diff-viewer" className="diff-viewer"
sx={{ sx={{
display: 'flex', display: 'flex',
position: 'relative', position: 'relative',
minWidth: 'fit-content',
minHeight: 'fit-content',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '14px', fontSize: '14px',
maxWidth: '100%',
...sx,
}} }}
/> />
</Box>
); );
}; };

View File

@ -521,7 +521,7 @@ const Message = (props: MessageProps): JSX.Element => {
let metadataView = <></>; let metadataView = <></>;
let metadata: ChatMessageMetaData | null = let metadata: ChatMessageMetaData | null =
'metadata' in message ? (message.metadata as ChatMessageMetaData) || null : null; 'metadata' in message ? (message.metadata as ChatMessageMetaData) || null : null;
if ('role' in message && message.role === 'user') { if ('role' in message && message.role !== 'assistant') {
metadata = null; metadata = null;
} }
if (metadata) { if (metadata) {

View File

@ -1,23 +1,9 @@
import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react'; import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react';
import { import { Box, Button, Tooltip, SxProps, Typography } from '@mui/material';
Box,
Button,
Tooltip,
SxProps,
Typography,
Tabs,
Tab,
Dialog,
DialogContent,
} from '@mui/material';
import { Send as SendIcon, Person as PersonIcon } from '@mui/icons-material'; import { Send as SendIcon, Person as PersonIcon } from '@mui/icons-material';
// import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing'; import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing';
import SaveIcon from '@mui/icons-material/Save'; import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
import UndoIcon from '@mui/icons-material/Undo';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import PreviewIcon from '@mui/icons-material/Preview';
import DifferenceIcon from '@mui/icons-material/Difference';
import EditDocumentIcon from '@mui/icons-material/EditDocument';
import { import {
ChatMessage, ChatMessage,
ChatSession, ChatSession,
@ -35,7 +21,6 @@ import PropagateLoader from 'react-spinners/PropagateLoader';
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField'; import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
import { Scrollable } from 'components/Scrollable'; import { Scrollable } from 'components/Scrollable';
import { StyledMarkdown } from './StyledMarkdown'; import { StyledMarkdown } from './StyledMarkdown';
import { DiffViewer } from './DiffViewer';
const emptyMetadata: ChatMessageMetaData = { const emptyMetadata: ChatMessageMetaData = {
model: 'qwen2.5', model: 'qwen2.5',
@ -64,126 +49,12 @@ const defaultMessage: ChatMessage = {
}; };
interface ResumeChatProps { interface ResumeChatProps {
onResumeChange: (resume: string) => void; // Callback when the resume changes onResumeChange: (prompt: string, resume: string) => void; // Callback when the resume changes
resume: string; resume: string;
session: string; // Session identifier for the chat session: string; // Session identifier for the chat
sx?: SxProps; // Optional styles for the component sx?: SxProps; // Optional styles for the component
} }
interface EditViewerProps {
original: string; // Original resume content
content: string; // Edited resume content
sx?: SxProps; // Optional styles for the component
onUpdate?: (content: string) => void; // Callback when the content is updated
}
const EditViewer = (props: EditViewerProps) => {
const { original, content, sx, onUpdate } = props;
const [tabValue, setTabValue] = useState<string>('raw');
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
if (newValue === 'diff') {
setTabValue(newValue);
} else {
setTabValue(newValue);
}
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ...sx }}>
<Tabs value={tabValue} onChange={handleTabChange} sx={{ m: 0 }}>
<Tab value="raw" icon={<EditDocumentIcon sx={{ height: 16 }} />} sx={{ height: 20 }} />
<Tab value="markdown" icon={<PreviewIcon sx={{ height: 16 }} />} sx={{ height: 20 }} />
<Tab value="diff" icon={<DifferenceIcon sx={{ height: 16 }} />} sx={{ height: 20 }} />
</Tabs>
{tabValue === 'raw' && (
<Box>
<pre>{content}</pre>
</Box>
)}
{tabValue === 'markdown' && <StyledMarkdown content={content} />}
<Dialog
open={tabValue === 'diff'}
onClose={(): void => {
setTabValue('raw');
}}
maxWidth="lg"
fullWidth
disableEscapeKeyDown={false}
fullScreen={true}
>
<DialogContent>
<Scrollable
sx={{
position: 'relative',
boxSizing: 'border-box',
maxHeight: '100%',
maxWidth: '100%',
width: '100%',
display: 'flex',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
p: 0,
gap: 1,
}}
>
<DiffViewer original={original} content={content} />
</Scrollable>
<Box
sx={{
display: 'flex',
width: '100%',
flexDirection: 'row',
justifyContent: 'space-between',
}}
>
<Tooltip title="Revert Changes">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'flex-end',
}}
>
<Button
variant="contained"
onClick={(): void => {
setTabValue('raw');
onUpdate && onUpdate(original);
}}
disabled={!onUpdate}
>
<UndoIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Save Changes">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'flex-end',
}}
>
<Button
variant="contained"
onClick={(): void => {
setTabValue('raw');
onUpdate && onUpdate(content);
}}
disabled={!onUpdate}
>
<SaveIcon />
</Button>
</span>
</Tooltip>
</Box>
</DialogContent>
</Dialog>
</Box>
);
};
const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>( const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
(props: ResumeChatProps, ref): JSX.Element => { (props: ResumeChatProps, ref): JSX.Element => {
const { const {
@ -215,6 +86,7 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [streaming, setStreaming] = useState<boolean>(false); const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const [lastPrompt, setLastPrompt] = useState<string>('');
const onDelete = async (session: ChatSession): Promise<void> => { const onDelete = async (session: ChatSession): Promise<void> => {
if (!session.id) { if (!session.id) {
@ -237,6 +109,7 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
const messageContent = message; const messageContent = message;
setStreaming(true); setStreaming(true);
setLastPrompt(message);
const chatMessage: ChatMessageUser = { const chatMessage: ChatMessageUser = {
sessionId: chatSession.id, sessionId: chatSession.id,
@ -267,6 +140,8 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
msg.extraContext = { msg.extraContext = {
isAnswer: true, isAnswer: true,
}; };
} else {
onResumeChange(lastPrompt, msg.content);
} }
setMessages(prev => { setMessages(prev => {
@ -424,23 +299,22 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
> >
{message.role === 'user' && ( {message.role === 'user' && (
<> <>
<PersonIcon /> <PersonIcon sx={{ mr: 1 }} />
<Typography>{message.content}</Typography> <Typography>{message.content}</Typography>
</> </>
)} )}
{message.role === 'assistant' && ( {message.role === 'assistant' && (
<> <>
{message.extraContext?.isAnswer ? ( {message.extraContext?.isAnswer ? (
<>
<QuestionAnswerIcon sx={{ mr: 1 }} />
<StyledMarkdown content={message.content} /> <StyledMarkdown content={message.content} />
</>
) : ( ) : (
<EditViewer <>
original={resume} <PrecisionManufacturingIcon sx={{ mr: 1 }} />
content={message.content} <StyledMarkdown content="Resume updated." />
onUpdate={(content: string): void => { </>
onResumeChange && onResumeChange(content);
}}
sx={{ maxWidth: '100%', boxSizing: 'border-box' }}
/>
)} )}
</> </>
)} )}

View File

@ -34,6 +34,8 @@ const Scrollable = forwardRef((props: ScrollableProps, ref) => {
flexGrow: 1, flexGrow: 1,
overflow: 'auto', overflow: 'auto',
position: 'relative', position: 'relative',
maxHeight: '100%',
minHeight: 0, // Ensure it can shrink to fit content
// backgroundColor: '#F5F5F5', // backgroundColor: '#F5F5F5',
...sx, ...sx,
}} }}

View File

@ -3,7 +3,6 @@ import React, { JSX, ReactElement, useEffect, useState } from 'react';
import { Outlet, useLocation, Routes, Route, matchPath } from 'react-router-dom'; import { Outlet, useLocation, Routes, Route, matchPath } from 'react-router-dom';
import { Box, Container, Paper } from '@mui/material'; import { Box, Container, Paper } from '@mui/material';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { SxProps, Theme } from '@mui/material';
import { darken } from '@mui/material/styles'; import { darken } from '@mui/material/styles';
import { Header } from 'components/layout/Header'; import { Header } from 'components/layout/Header';
import { Scrollable } from 'components/Scrollable'; import { Scrollable } from 'components/Scrollable';
@ -23,12 +22,11 @@ export type NavigationLinkType = {
interface BackstoryPageContainerProps { interface BackstoryPageContainerProps {
children?: React.ReactNode; children?: React.ReactNode;
sx?: SxProps<Theme>;
variant?: 'normal' | 'fullWidth'; variant?: 'normal' | 'fullWidth';
} }
const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element => { const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element => {
const { children, sx, variant = 'normal' } = props; const { children, variant = 'normal' } = props;
console.log({ variant }); console.log({ variant });
return ( return (
<Container <Container
@ -43,13 +41,13 @@ const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element
maxWidth: variant === 'normal' ? '1024px' : '100% !important', maxWidth: variant === 'normal' ? '1024px' : '100% !important',
height: '100%', height: '100%',
minHeight: 0, minHeight: 0,
...sx,
}} }}
> >
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
p: { xs: 0, sm: 0.5 }, p: 0,
m: 0,
flexGrow: 1, flexGrow: 1,
minHeight: 'min-content', minHeight: 'min-content',
}} }}
@ -60,7 +58,7 @@ const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element
display: 'flex', display: 'flex',
flexGrow: 1, flexGrow: 1,
m: 0, m: 0,
p: 0.5, p: 0,
minHeight: 'min-content', minHeight: 'min-content',
backgroundColor: 'background.paper', backgroundColor: 'background.paper',
borderRadius: 0.5, borderRadius: 0.5,

View File

@ -194,8 +194,10 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
{candidate.location && ( {candidate.location && (
<Typography variant="body2" sx={{ mb: 1 }}> <Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {candidate.location.city},{' '} <strong>Location:</strong>{' '}
{candidate.location.state || candidate.location.country} {candidate.location.city
? `${candidate.location.city}, ${candidate.location.state}`
: candidate.location.text}
</Typography> </Typography>
)} )}
{candidate.email && ( {candidate.email && (

View File

@ -1,152 +0,0 @@
/* A4 Portrait simulation for MuiMarkdown */
.a4-document .MuiTypography-root {
font-family: 'Roboto', 'Times New Roman', serif;
}
.a4-document {
/* display: flex; */
/* position: relative; */
/* A4 dimensions: 210mm x 297mm */
width: 210mm;
min-height: 297mm;
/* Alternative pixel-based approach (96 DPI) */
/* width: 794px; */
/* height: 1123px; */
/* Document styling */
background: white;
padding: 12mm; /* 1/4" margins all around */
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e0e0e0;
/* Typography for document feel */
font-family: 'Roboto', 'Times New Roman', serif;
font-size: 12pt;
line-height: 1.6;
color: #333;
/* Page break lines - repeating dotted lines every A4 height */
background-image:
repeating-linear-gradient(
#ddd,
#ddd 12mm,
transparent calc(12mm + 1px),
transparent calc(285mm - 1px), /* 297mm - 8mm top/bottom margins */
#ddd calc(285mm),
#ddd 297mm
);
background-size: 100% 297mm;
background-repeat: repeat-y;
/* Ensure proper page breaks for printing */
page-break-after: always;
/* Prevent content overflow */
box-sizing: border-box;
overflow: hidden;
}
/* Container to center the document */
.document-container {
display: flex;
justify-content: center;
background-color: #f5f5f5;
min-height: 100vh;
padding: 20px 0;
}
/* Responsive adjustments */
@media screen and (max-width: 900px) {
.a4-document {
width: 95vw;
height: auto;
min-height: 134vw; /* Maintains A4 aspect ratio (297/210 ≈ 1.414) */
margin: 10px auto;
padding: 6vw;
}
}
/* Print styles */
@media print {
.document-container {
background: none;
padding: 0mm !important;
margin: 0mm !important;
}
.a4-document {
width: 210mm;
margin: 0;
padding: 0;
box-shadow: none;
border: none;
page-break-after: always;
}
}
/* Additional MuiMarkdown specific adjustments */
.a4-document h1,
.a4-document h2,
.a4-document h3,
.a4-document h4,
.a4-document h5,
.a4-document h6 {
font-size: 1em;
margin-top: 0.25em;
margin-bottom: 0.25em;
}
/* Put after above so they take precedence */
.a4-document h1,
.a4-document h2 {
font-size: 1.1em;
}
.a4-document p {
margin-bottom: 1em;
text-align: justify;
}
.a4-document ul,
.a4-document ol {
margin-bottom: 1em;
padding-left: 2em;
}
.a4-document blockquote {
margin: 1em 0;
padding-left: 1em;
border-left: 3px solid #ccc;
font-style: italic;
}
.a4-document code {
background-color: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.a4-document pre {
background-color: #f5f5f5;
padding: 1em;
border-radius: 5px;
overflow-x: auto;
margin: 1em 0;
}
.BackstoryResumeHeader {
gap: 1rem;
display: flex;
/* flex-direction: column; */
/* border: 3px solid orange; */
}
.BackstoryResumeHeader p {
/* border: 3px solid purple; */
margin: 0 !important;
}

View File

@ -24,13 +24,17 @@ import {
Select, Select,
MenuItem, MenuItem,
InputLabel, InputLabel,
Theme,
Chip, Chip,
Alert, Alert,
Stack, Stack,
SelectChangeEvent, SelectChangeEvent,
} from '@mui/material'; } from '@mui/material';
import PrintIcon from '@mui/icons-material/Print'; import PrintIcon from '@mui/icons-material/Print';
// import SaveIcon from '@mui/icons-material/Save';
// import UndoIcon from '@mui/icons-material/Undo';
// import PreviewIcon from '@mui/icons-material/Preview';
import DifferenceIcon from '@mui/icons-material/Difference';
// import EditDocumentIcon from '@mui/icons-material/EditDocument';
import { import {
Delete as DeleteIcon, Delete as DeleteIcon,
Restore as RestoreIcon, Restore as RestoreIcon,
@ -41,19 +45,15 @@ import {
Person as PersonIcon, Person as PersonIcon,
Schedule as ScheduleIcon, Schedule as ScheduleIcon,
ModelTraining, ModelTraining,
Email as EmailIcon,
Phone as PhoneIcon,
LocationOn as LocationIcon,
History as HistoryIcon, History as HistoryIcon,
RestoreFromTrash as RestoreFromTrashIcon, RestoreFromTrash as RestoreFromTrashIcon,
Refresh as RefreshIcon, Refresh as RefreshIcon,
PrecisionManufacturing,
// Language as WebsiteIcon, // Language as WebsiteIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import InputIcon from '@mui/icons-material/Input';
import TuneIcon from '@mui/icons-material/Tune'; import TuneIcon from '@mui/icons-material/Tune';
import PreviewIcon from '@mui/icons-material/Preview'; import PreviewIcon from '@mui/icons-material/Preview';
import EditDocumentIcon from '@mui/icons-material/EditDocument'; import EditDocumentIcon from '@mui/icons-material/EditDocument';
import { parsePhoneNumberFromString } from 'libphonenumber-js';
import { useReactToPrint } from 'react-to-print'; import { useReactToPrint } from 'react-to-print';
@ -63,12 +63,14 @@ import { StyledMarkdown } from 'components/StyledMarkdown';
import { Resume } from 'types/types'; import { Resume } from 'types/types';
import { BackstoryTextField } from 'components/BackstoryTextField'; import { BackstoryTextField } from 'components/BackstoryTextField';
import { JobInfo } from './JobInfo'; import { JobInfo } from './JobInfo';
import './ResumeInfo.css';
import { Scrollable } from 'components/Scrollable'; import { Scrollable } from 'components/Scrollable';
import * as Types from 'types/types'; import * as Types from 'types/types';
import { StreamingOptions } from 'services/api-client'; import { StreamingOptions } from 'services/api-client';
import { StatusBox, StatusIcon } from './StatusIcon'; import { StatusBox, StatusIcon } from './StatusIcon';
import { ResumeChat } from 'components/ResumeChat'; import { ResumeChat } from 'components/ResumeChat';
import { DiffViewer } from 'components/DiffViewer';
import { ResumePreview, resumeStyles } from './ResumePreview';
import { useNavigate } from 'react-router-dom';
interface ResumeInfoProps { interface ResumeInfoProps {
resume: Resume; resume: Resume;
@ -88,457 +90,9 @@ interface ResumeRevision {
jobId: string; jobId: string;
} }
// Resume Style Definitions
interface ResumeStyle {
name: string;
description: string;
headerStyle: SxProps<Theme>;
footerStyle: SxProps<Theme>;
contentStyle: SxProps<Theme>;
markdownStyle: SxProps<Theme>;
color: {
primary: string;
secondary: string;
accent: string;
text: string;
background: string;
};
}
const generateResumeStyles = () => {
const defaultStyle = {
display: 'flex',
flexDirection: 'row',
};
return {
classic: {
name: 'Classic',
description: 'Traditional, professional serif design',
headerStyle: {
...defaultStyle,
fontFamily: '"Times New Roman", Times, serif',
borderBottom: '2px solid #2c3e50',
paddingBottom: 2,
marginBottom: 3,
},
footerStyle: {
fontFamily: '"Times New Roman", Times, serif',
borderTop: '2px solid #2c3e50',
paddingTop: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Times New Roman", Times, serif',
lineHeight: 1.6,
color: '#2c3e50',
},
markdownStyle: {
fontFamily: '"Times New Roman", Times, serif',
'& h1, & h2, & h3': {
fontFamily: '"Times New Roman", Times, serif',
color: '#2c3e50',
borderBottom: '1px solid #bdc3c7',
paddingBottom: 1,
marginBottom: 2,
},
'& p, & li': {
lineHeight: 1.6,
marginBottom: 1,
},
'& ul': {
paddingLeft: 3,
},
},
color: {
primary: '#2c3e50',
secondary: '#34495e',
accent: '#3498db',
text: '#2c3e50',
background: '#ffffff',
},
},
modern: {
name: 'Modern',
description: 'Clean, minimalist sans-serif layout',
headerStyle: {
...defaultStyle,
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
borderLeft: '4px solid #3498db',
paddingLeft: 2,
marginBottom: 3,
backgroundColor: '#f8f9fa',
padding: 2,
borderRadius: 1,
},
footerStyle: {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
borderLeft: '4px solid #3498db',
backgroundColor: '#f8f9fa',
paddingTop: 2,
borderRadius: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
lineHeight: 1.5,
color: '#2c3e50',
},
markdownStyle: {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
color: '#3498db',
fontWeight: 300,
marginBottom: 1.5,
},
'& h1': {
fontSize: '1.75rem',
},
'& h2': {
fontSize: '1.5rem',
},
'& h3': {
fontSize: '1.25rem',
},
'& p, & li': {
lineHeight: 1.5,
marginBottom: 0.75,
},
'& ul': {
paddingLeft: 2.5,
},
},
color: {
primary: '#3498db',
secondary: '#2c3e50',
accent: '#e74c3c',
text: '#2c3e50',
background: '#ffffff',
},
},
creative: {
name: 'Creative',
description: 'Colorful, unique design with personality',
headerStyle: {
...defaultStyle,
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#ffffff',
padding: 2.5,
borderRadius: 1.5,
marginBottom: 3,
},
footerStyle: {
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#ffffff',
paddingTop: 2,
borderRadius: 1.5,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Open Sans", Arial, sans-serif',
lineHeight: 1.6,
color: '#444444',
},
markdownStyle: {
fontFamily: '"Open Sans", Arial, sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
color: '#667eea',
fontWeight: 600,
marginBottom: 2,
},
'& h1': {
fontSize: '1.5rem',
},
'& h2': {
fontSize: '1.25rem',
},
'& h3': {
fontSize: '1.1rem',
},
'& p, & li': {
lineHeight: 1.6,
marginBottom: 1,
color: '#444444',
},
'& strong': {
color: '#764ba2',
fontWeight: 600,
},
'& ul': {
paddingLeft: 3,
},
},
color: {
primary: '#667eea',
secondary: '#764ba2',
accent: '#f093fb',
text: '#444444',
background: '#ffffff',
},
},
corporate: {
name: 'Corporate',
description: 'Formal, structured business format',
headerStyle: {
...defaultStyle,
fontFamily: '"Arial", sans-serif',
border: '2px solid #34495e',
padding: 2.5,
marginBottom: 3,
backgroundColor: '#ecf0f1',
},
footerStyle: {
fontFamily: '"Arial", sans-serif',
border: '2px solid #34495e',
backgroundColor: '#ecf0f1',
paddingTop: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Arial", sans-serif',
lineHeight: 1.4,
color: '#2c3e50',
},
markdownStyle: {
fontFamily: '"Arial", sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Arial", sans-serif',
color: '#34495e',
fontWeight: 'bold',
textTransform: 'uppercase',
fontSize: '0.875rem',
letterSpacing: '1px',
marginBottom: 1.5,
borderBottom: '1px solid #bdc3c7',
paddingBottom: 0.5,
},
'& h1': {
fontSize: '1rem',
},
'& h2': {
fontSize: '0.875rem',
},
'& h3': {
fontSize: '0.75rem',
},
'& p, & li': {
lineHeight: 1.4,
marginBottom: 0.75,
fontSize: '0.75rem',
},
'& ul': {
paddingLeft: 2,
},
},
color: {
primary: '#34495e',
secondary: '#2c3e50',
accent: '#95a5a6',
text: '#2c3e50',
background: '#ffffff',
},
},
};
};
const resumeStyles: Record<string, ResumeStyle> = generateResumeStyles();
// Styled Header Component
interface BackstoryStyledResumeProps {
candidate: Types.Candidate;
job?: Types.Job;
style: ResumeStyle;
}
const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, job, style }) => {
return (
<>
<Box
className="BackstoryResumeFooter"
sx={{
...style.footerStyle,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
color: style.color.secondary,
}}
>
Dive deeper into my qualifications at Backstory...
<Box
component="img"
src={`/api/1.0/candidates/qr-code/${candidate.id || ''}/${(job && job.id) || ''}`}
alt="QR Code"
className="qr-code"
sx={{ display: 'flex', mt: 1, mb: 1 }}
/>
{candidate?.username
? `${window.location.protocol}://${window.location.host}/u/${candidate?.username}`
: 'backstory'}
</Box>
<Box sx={{ pb: 2 }}>&nbsp;</Box>
</>
);
};
const StyledHeader: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }) => {
const phone = parsePhoneNumberFromString(candidate.phone || '', 'US');
return (
<Box className="BackstoryResumeHeader" sx={style.headerStyle}>
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<Box sx={{ display: 'flex' }}>
<Typography
variant="h4"
sx={{
fontWeight: 'bold',
mb: 1,
color: style.name === 'creative' ? '#ffffff' : style.color.primary,
fontFamily: 'inherit',
}}
>
{candidate.fullName}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
gap: 1,
}}
>
{candidate.description && (
<Box sx={{ display: 'flex' }}>
<Typography
variant="h6"
sx={{
mb: 2,
fontWeight: 300,
color: style.name === 'creative' ? '#ffffff' : style.color.secondary,
fontFamily: 'inherit',
fontSize: '0.8rem !important',
}}
>
{candidate.description}
</Typography>
</Box>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flexWrap: 'wrap',
alignContent: 'center',
flexGrow: 1,
minWidth: 'fit-content',
gap: 1,
}}
>
{candidate.email && (
<Box sx={{ display: 'flex', alignItems: 'center', m: 0, p: 0 }}>
<EmailIcon
fontSize="small"
sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
/>
<Typography
variant="body2"
sx={{
color: style.name === 'creative' ? '#ffffff' : style.color.text,
fontFamily: 'inherit',
}}
>
{candidate.email}
</Typography>
</Box>
)}
{phone?.isValid() && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PhoneIcon
fontSize="small"
sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
/>
<Typography
variant="body2"
sx={{
color: style.name === 'creative' ? '#ffffff' : style.color.text,
fontFamily: 'inherit',
}}
>
{phone.formatInternational()}
</Typography>
</Box>
)}
{candidate.location && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<LocationIcon
fontSize="small"
sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
/>
<Typography
variant="body2"
sx={{
color: style.name === 'creative' ? '#ffffff' : style.color.text,
fontFamily: 'inherit',
}}
>
{candidate.location.city
? `${candidate.location.city}, ${candidate.location.state}`
: candidate.location.text}
</Typography>
</Box>
)}
</Box>
</Box>
</Box>
</Box>
);
};
const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => { const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const navigate = useNavigate();
const { resume } = props; const { resume } = props;
const { user, apiClient } = useAuth(); const { user, apiClient } = useAuth();
const { sx, variant = 'normal' } = props; const { sx, variant = 'normal' } = props;
@ -551,6 +105,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const [editContent, setEditContent] = useState<string>(''); const [editContent, setEditContent] = useState<string>('');
const [editSystemPrompt, setEditSystemPrompt] = useState<string>(''); const [editSystemPrompt, setEditSystemPrompt] = useState<string>('');
const [editPrompt, setEditPrompt] = useState<string>(''); const [editPrompt, setEditPrompt] = useState<string>('');
const [lastRevision, setLastRevision] = useState<Resume | null>(null);
const [saving, setSaving] = useState<boolean>(false); const [saving, setSaving] = useState<boolean>(false);
const [tabValue, setTabValue] = useState('markdown'); const [tabValue, setTabValue] = useState('markdown');
const [jobTabValue, setJobTabValue] = useState('chat'); const [jobTabValue, setJobTabValue] = useState('chat');
@ -630,6 +185,9 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
// Handle revision selection // Handle revision selection
const handleRevisionChange = async (event: SelectChangeEvent<string>): Promise<void> => { const handleRevisionChange = async (event: SelectChangeEvent<string>): Promise<void> => {
const newRevisionId = event.target.value; const newRevisionId = event.target.value;
if (selectedRevision === 'current') {
setLastRevision(activeResume);
}
setSelectedRevision(newRevisionId); setSelectedRevision(newRevisionId);
await loadRevisionContent(newRevisionId); await loadRevisionContent(newRevisionId);
}; };
@ -638,6 +196,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const restoreRevision = (): void => { const restoreRevision = (): void => {
setEditContent(revisionContent); setEditContent(revisionContent);
setSelectedRevision('current'); setSelectedRevision('current');
setLastRevision(null);
setSnack('Revision restored to editor.'); setSnack('Revision restored to editor.');
}; };
@ -674,14 +233,14 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
setActiveResume({ ...resume }); setActiveResume({ ...resume });
}; };
const handleSave = async (): Promise<void> => { const handleSave = async (prompt = ''): Promise<void> => {
setSaving(true); setSaving(true);
try { try {
const resumeUpdate = { const resumeUpdate = {
...activeResume, ...activeResume,
resume: editContent, resume: editContent,
systemPrompt: editSystemPrompt, systemPrompt: editSystemPrompt,
prompt: editPrompt, prompt: prompt || editPrompt,
}; };
const result = await apiClient.updateResume(resumeUpdate); const result = await apiClient.updateResume(resumeUpdate);
console.log('Resume updated:', result); console.log('Resume updated:', result);
@ -705,6 +264,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
setEditSystemPrompt(activeResume.systemPrompt || ''); setEditSystemPrompt(activeResume.systemPrompt || '');
setEditPrompt(activeResume.prompt || ''); setEditPrompt(activeResume.prompt || '');
setSelectedRevision('current'); setSelectedRevision('current');
setLastRevision(null);
setRevisionContent(activeResume.resume); setRevisionContent(activeResume.resume);
setEditDialogOpen(true); setEditDialogOpen(true);
}; };
@ -773,6 +333,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => { const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
if (newValue === 'print') { if (newValue === 'print') {
console.log('Printing resume...');
reactToPrintFn(); reactToPrintFn();
return; return;
} }
@ -878,8 +439,20 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Job ID: {activeResume.job?.id} Job ID: {activeResume.job?.id}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography
Resume ID: {activeResume.id} variant="caption"
color="text.secondary"
sx={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: 1 }}
>
Resume ID:
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
navigate(`/chat/${activeResume.id}`);
}}
>
{activeResume.id}
</Box>
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@ -1042,14 +615,12 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<Tabs value={tabValue} onChange={handleTabChange}> <Tabs value={tabValue} onChange={handleTabChange}>
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" /> <Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
{activeResume.systemPrompt && ( {lastRevision && <Tab value="diff" icon={<DifferenceIcon />} label="Changes" />}
<Tab value="systemPrompt" icon={<TuneIcon />} label="System Prompt" />
)}
{activeResume.systemPrompt && (
<Tab value="prompt" icon={<InputIcon />} label="Prompt" />
)}
<Tab value="preview" icon={<PreviewIcon />} label="Preview" /> <Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab value="print" icon={<PrintIcon />} label="Print" /> <Tab value="print" icon={<PrintIcon />} label="Print" />
{activeResume.systemPrompt && (
<Tab value="context" icon={<TuneIcon />} label="Context" />
)}
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" /> <Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
</Tabs> </Tabs>
</Box> </Box>
@ -1162,6 +733,18 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Tooltip> </Tooltip>
{selectedRevision !== 'current' && ( {selectedRevision !== 'current' && (
<>
<Tooltip title="See changes against current version">
<Button
size="small"
startIcon={<PrecisionManufacturing />}
onClick={() => setTabValue('diff')}
disabled={loadingRevision}
>
Changes
</Button>
</Tooltip>
<Tooltip title="Restore this revision to editor"> <Tooltip title="Restore this revision to editor">
<Button <Button
size="small" size="small"
@ -1172,6 +755,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
Restore Restore
</Button> </Button>
</Tooltip> </Tooltip>
</>
)} )}
</Stack> </Stack>
@ -1213,6 +797,9 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
flexGrow: 1, flexGrow: 1,
flex: 1, flex: 1,
overflowY: 'auto', overflowY: 'auto',
fontFamily: 'monospace',
backgroundColor: '#fafafa',
fontSize: '12px',
}} }}
placeholder="Enter resume content..." placeholder="Enter resume content..."
/> />
@ -1240,22 +827,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
)} )}
</> </>
)} )}
{tabValue === 'systemPrompt' && ( {tabValue === 'context' && (
<BackstoryTextField <StyledMarkdown content={activeResume.systemPrompt || ''} />
value={editSystemPrompt}
onChange={(value): void => setEditSystemPrompt(value)}
style={{
position: 'relative',
maxHeight: '100%',
width: '100%',
display: 'flex',
minHeight: '100%',
flexGrow: 1,
flex: 1,
overflowY: 'auto',
}}
placeholder="Edit system prompt..."
/>
)} )}
{tabValue === 'prompt' && ( {tabValue === 'prompt' && (
<BackstoryTextField <BackstoryTextField
@ -1275,50 +848,20 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
placeholder="Edit prompt..." placeholder="Edit prompt..."
/> />
)} )}
{tabValue === 'preview' && ( {tabValue === 'diff' && lastRevision && (
<DiffViewer
changeLog={activeResume.prompt || ''}
original={{ content: revisionContent, name: 'original' }}
modified={{ content: lastRevision.resume, name: 'modified' }}
/>
)}
{tabValue === 'preview' && activeResume.candidate && (
<Box <Box
className="document-container" className="document-container"
ref={printContentRef} ref={printContentRef}
sx={currentStyle.contentStyle} sx={currentStyle.contentStyle}
> >
<Box <ResumePreview resume={activeResume} selectedStyle={selectedStyle} />
className="a4-document"
sx={{
backgroundColor: currentStyle.color.background,
padding: 5,
minHeight: '100vh',
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
}}
>
{/* Custom Header */}
{activeResume.candidate && (
<StyledHeader candidate={activeResume.candidate} style={currentStyle} />
)}
{/* Styled Markdown Content */}
<Box sx={currentStyle.markdownStyle}>
<StyledMarkdown
sx={{
position: 'relative',
maxHeight: '100%',
display: 'flex',
flexGrow: 1,
flex: 1,
...currentStyle.markdownStyle,
}}
content={editContent || activeResume.resume || ''}
/>
</Box>
{/* QR Code Footer */}
{activeResume.candidate && activeResume.job && (
<StyledFooter
candidate={activeResume.candidate}
job={activeResume.job}
style={currentStyle}
/>
)}
</Box>
</Box> </Box>
)} )}
</Scrollable> </Scrollable>
@ -1363,14 +906,22 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<ResumeChat <ResumeChat
session={activeResume.id || ''} session={activeResume.id || ''}
resume={editContent} resume={editContent}
onResumeChange={(newResume: string): void => { onResumeChange={(prompt: string, newResume: string): void => {
if (newResume !== editContent) {
handleSave(prompt).then(() => {
setEditContent(newResume); setEditContent(newResume);
setActiveResume({ ...activeResume, resume: newResume }); setLastRevision(activeResume);
setActiveResume({ ...activeResume, prompt, resume: newResume });
});
}
}} }}
sx={{ sx={{
m: 1, m: 1,
p: 1, p: 1,
flexGrow: 1, flexGrow: 1,
position: 'relative',
maxWidth: 'fit-content',
minWidth: '100%',
}} }}
/> />
)} )}
@ -1381,7 +932,9 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<DialogActions> <DialogActions>
<Button onClick={(): void => setEditDialogOpen(false)}>Cancel</Button> <Button onClick={(): void => setEditDialogOpen(false)}>Cancel</Button>
<Button <Button
onClick={handleSave} onClick={() => {
handleSave();
}}
variant="contained" variant="contained"
disabled={saving} disabled={saving}
startIcon={<SaveIcon />} startIcon={<SaveIcon />}

View File

@ -39,6 +39,7 @@ import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedResume } from 'hooks/GlobalContext'; // Assuming similar context exists import { useAppState, useSelectedResume } from 'hooks/GlobalContext'; // Assuming similar context exists
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Resume } from 'types/types'; import { Resume } from 'types/types';
import { formatDate } from 'utils/formatDate';
type SortField = 'updatedAt' | 'createdAt' | 'candidateId' | 'jobId'; type SortField = 'updatedAt' | 'createdAt' | 'candidateId' | 'jobId';
type SortOrder = 'asc' | 'desc'; type SortOrder = 'asc' | 'desc';
@ -214,16 +215,6 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
const sortedResumes = sortResumes(filteredResumes, sortField, sortOrder); const sortedResumes = sortResumes(filteredResumes, sortField, sortOrder);
const formatDate = (date: Date | undefined): string => {
if (!date) return 'N/A';
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
...(isMobile ? {} : { year: 'numeric' }),
...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' }),
}).format(date);
};
const getSortIcon = (field: SortField): JSX.Element => { const getSortIcon = (field: SortField): JSX.Element => {
if (sortField !== field) return <></>; if (sortField !== field) return <></>;
return sortOrder === 'asc' ? ( return sortOrder === 'asc' ? (
@ -436,7 +427,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
noWrap noWrap
sx={{ display: 'block', fontSize: '0.7rem' }} sx={{ display: 'block', fontSize: '0.7rem' }}
> >
{formatDate(resume.updatedAt)} {formatDate(resume.updatedAt, isMobile, isSmall)}
</Typography> </Typography>
)} )}
</TableCell> </TableCell>
@ -469,7 +460,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
{!isMobile && ( {!isMobile && (
<TableCell sx={{ py: 0.5, px: 1 }}> <TableCell sx={{ py: 0.5, px: 1 }}>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}> <Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
{formatDate(resume.updatedAt)} {formatDate(resume.updatedAt, isMobile, isSmall)}
</Typography> </Typography>
{resume.createdAt && ( {resume.createdAt && (
<Typography <Typography
@ -477,7 +468,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
color="text.secondary" color="text.secondary"
sx={{ display: 'block', fontSize: '0.7rem' }} sx={{ display: 'block', fontSize: '0.7rem' }}
> >
Created: {formatDate(resume.createdAt)} Created: {formatDate(resume.createdAt, isMobile, isSmall)}
</Typography> </Typography>
)} )}
</TableCell> </TableCell>

View File

@ -61,7 +61,8 @@ export const navigationConfig: NavigationConfig = {
{ {
id: 'chat', id: 'chat',
label: 'Candidate Chat', label: 'Candidate Chat',
path: '/chat', path: '/chat/:resumeId?',
variant: 'fullWidth',
icon: <ChatIcon />, icon: <ChatIcon />,
component: <CandidateChatPage />, component: <CandidateChatPage />,
userTypes: ['guest', 'candidate', 'employer'], userTypes: ['guest', 'candidate', 'employer'],

View File

@ -1,5 +1,5 @@
import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react'; import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react';
import { Box, Paper, Button, Tooltip, SxProps } from '@mui/material'; import { Box, Button, Tooltip, SxProps } from '@mui/material';
import { Send as SendIcon } from '@mui/icons-material'; import { Send as SendIcon } from '@mui/icons-material';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { import {
@ -11,6 +11,7 @@ import {
ChatMessageStatus, ChatMessageStatus,
ChatMessageMetaData, ChatMessageMetaData,
CandidateQuestion, CandidateQuestion,
Resume,
} from 'types/types'; } from 'types/types';
import { ConversationHandle } from 'components/Conversation'; import { ConversationHandle } from 'components/Conversation';
import { Message } from 'components/Message'; import { Message } from 'components/Message';
@ -22,6 +23,9 @@ import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryT
import { BackstoryQuery } from 'components/BackstoryQuery'; import { BackstoryQuery } from 'components/BackstoryQuery';
import { CandidatePicker } from 'components/ui/CandidatePicker'; import { CandidatePicker } from 'components/ui/CandidatePicker';
import { Scrollable } from 'components/Scrollable'; import { Scrollable } from 'components/Scrollable';
import { useParams } from 'react-router-dom';
import ResumePreview from 'components/ui/ResumePreview';
import { formatDate } from 'utils/formatDate';
const emptyMetadata: ChatMessageMetaData = { const emptyMetadata: ChatMessageMetaData = {
model: 'qwen2.5', model: 'qwen2.5',
@ -55,6 +59,8 @@ interface CandidateChatPageProps {
const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>( const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>(
(props: CandidateChatPageProps, ref): JSX.Element => { (props: CandidateChatPageProps, ref): JSX.Element => {
const { resumeId } = useParams<{ resumeId?: string }>();
const [resume, setResume] = useState<Resume | null>(null);
const { sx } = props; const { sx } = props;
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const { selectedCandidate } = useSelectedCandidate(); const { selectedCandidate } = useSelectedCandidate();
@ -72,6 +78,19 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
const [streaming, setStreaming] = useState<boolean>(false); const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!resumeId || resume) return;
apiClient
.getResume(resumeId)
.then(resume => {
setResume(resume);
})
.catch(error => {
console.error('Failed to load resume:', error);
setSnack('Failed to load resume', 'error');
});
}, [resumeId, resume, apiClient, setSnack]);
const onDelete = async (session: ChatSession): Promise<void> => { const onDelete = async (session: ChatSession): Promise<void> => {
if (!session.id) { if (!session.id) {
return; return;
@ -101,6 +120,11 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
status: 'done', status: 'done',
type: 'text', type: 'text',
timestamp: new Date(), timestamp: new Date(),
extraContext: {
candidateId: resume?.job?.id,
jobId: resume?.job?.id || '',
resumeId: resume?.id || '',
},
}; };
setProcessingMessage({ setProcessingMessage({
@ -176,8 +200,10 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
apiClient apiClient
.getOrCreateChatSession( .getOrCreateChatSession(
selectedCandidate, selectedCandidate,
`Backstory chat with ${selectedCandidate.fullName}`, resumeId
'candidate_chat' ? `Backstory Resume chat ${resumeId}`
: `Backstory Chat with ${selectedCandidate.fullName}`,
resumeId ? 'resume_chat' : 'candidate_chat'
) )
.then(session => { .then(session => {
setChatSession(session); setChatSession(session);
@ -222,7 +248,12 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
type: 'text', type: 'text',
status: 'done', status: 'done',
timestamp: new Date(), timestamp: new Date(),
content: `Welcome to the Backstory Chat about ${selectedCandidate.fullName}. Ask any questions you have about ${selectedCandidate.firstName}.`, content:
`Welcome to the Backstory Chat about ${selectedCandidate.fullName}` +
(resume && ` and the ${resume.job?.title} position at ${resume.job?.company}`) +
`. Ask any questions you have about ${selectedCandidate.firstName}'${
selectedCandidate.firstName.slice(-1) !== 's' ? 's' : ''
} resume or skills.`,
metadata: emptyMetadata, metadata: emptyMetadata,
}; };
@ -235,19 +266,63 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
ref={ref} ref={ref}
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'row',
height: '100%' /* Restrict to main-container's height */, height: '100%' /* Restrict to main-container's height */,
width: '100%', width: '100%',
minHeight: 0 /* Prevent flex overflow */, minHeight: 0 /* Prevent flex overflow */,
maxHeight: 'min-content', maxHeight: '100%',
'& > *:not(.Scrollable)': { '& > *:not(.Scrollable)': {
flexShrink: 0 /* Prevent shrinking */, flexShrink: 0 /* Prevent shrinking */,
}, },
position: 'relative',
...sx, ...sx,
p: 0,
m: 0,
backgroundColor: '#D3CDBF' /* Warm Gray */,
}} }}
> >
<Paper elevation={2} sx={{ m: 1, p: 1 }}> {resume && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '50%',
maxHeight: '100%',
p: 0,
m: 0,
position: 'relative',
backgroundColor: '#f5f5f5',
}}
>
<Box
sx={{
position: 'relative',
display: 'flex',
justifyContent: 'center',
p: 1,
}}
>
<strong>{resume.job?.title}</strong>&nbsp;at&nbsp;
<strong>{resume.job?.company}</strong>. Last updated{' '}
{formatDate(resume.updatedAt, false, true)}.
</Box>
<Scrollable sx={{ m: 0 }}>
<ResumePreview shadeMargins={false} resume={resume} />
</Scrollable>
</Box>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '50%',
p: 1,
m: resume ? 0 : '0 auto',
position: 'relative',
backgroundColor: 'background.paper',
}}
>
{!resume && (
<CandidateInfo <CandidateInfo
key={selectedCandidate.username} key={selectedCandidate.username}
action={`Chat with Backstory about ${selectedCandidate.firstName}`} action={`Chat with Backstory about ${selectedCandidate.firstName}`}
@ -255,40 +330,21 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
candidate={selectedCandidate} candidate={selectedCandidate}
variant="normal" variant="normal"
sx={{ sx={{
flexShrink: 1,
width: '100%', width: '100%',
maxHeight: 0, maxHeight: 0,
minHeight: 'min-content', minHeight: 'min-content',
}} // Prevent header from shrinking }} // Prevent header from shrinking
/> />
{/* <Button )}
sx={{ maxWidth: 'max-content' }}
onClick={(): void => {
setSelectedCandidate(null);
}}
variant="contained"
>
Change Candidates
</Button> */}
</Paper>
{/* Chat Interface */} {/* Chat Interface */}
{/* Scrollable Messages Area */} {/* Scrollable Messages Area */}
{chatSession && ( {chatSession && (
<Scrollable <Scrollable
sx={{ sx={{
position: 'relative',
maxHeight: '100%',
width: '100%', width: '100%',
display: 'flex',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
pt: 2,
pl: 1,
pr: 1,
pb: 2,
}} }}
> >
<Box sx={{ display: 'flex', flexGrow: 1 }}></Box>
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage }} />} {messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage }} />}
{messages.map((message: ChatMessage) => ( {messages.map((message: ChatMessage) => (
<Message key={message.id} {...{ chatSession, message }} /> <Message key={message.id} {...{ chatSession, message }} />
@ -321,7 +377,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
</Scrollable> </Scrollable>
)} )}
{selectedCandidate.questions?.length !== 0 && ( {selectedCandidate.questions?.length !== 0 && (
<Box sx={{ diplay: 'flex', flexDirection: 'column', gap: 1, p: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, p: 1, flex: 0 }}>
{' '} {' '}
{selectedCandidate.questions?.map((q, i) => ( {selectedCandidate.questions?.map((q, i) => (
<BackstoryQuery key={i} question={q} submitQuery={handleSubmitQuestion} /> <BackstoryQuery key={i} question={q} submitQuery={handleSubmitQuestion} />
@ -329,7 +385,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
</Box> </Box>
)} )}
{/* Fixed Message Input */} {/* Fixed Message Input */}
<Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
<DeleteConfirmation <DeleteConfirmation
onDelete={(): void => { onDelete={(): void => {
chatSession && onDelete(chatSession); chatSession && onDelete(chatSession);
@ -359,7 +415,8 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
variant="contained" variant="contained"
onClick={(): void => { onClick={(): void => {
sendMessage( sendMessage(
(backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || '' (backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) ||
''
); );
}} }}
disabled={streaming || loading} disabled={streaming || loading}
@ -370,6 +427,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
</Tooltip> </Tooltip>
</Box> </Box>
</Box> </Box>
</Box>
); );
} }
); );

View File

@ -752,12 +752,12 @@ class ApiClient {
}>(response); }>(response);
} }
async getResume(resumeId: string): Promise<{ success: boolean; resume: Types.Resume }> { async getResume(resumeId: string): Promise<Types.Resume> {
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, { const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, {
headers: this.defaultHeaders, headers: this.defaultHeaders,
}); });
return handleApiResponse<{ success: boolean; resume: Types.Resume }>(response); return this.handleApiResponseWithConversion<Types.Resume>(response, 'Resume');
} }
async deleteResume(resumeId: string): Promise<{ success: boolean; message: string }> { async deleteResume(resumeId: string): Promise<{ success: boolean; message: string }> {

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-15T16:43:21.492940 // Generated on: 2025-07-16T21:30:37.986984
// DO NOT EDIT MANUALLY - This file is auto-generated // DO NOT EDIT MANUALLY - This file is auto-generated
// ============================ // ============================
@ -19,7 +19,7 @@ export type ApiStatusType = "streaming" | "status" | "done" | "error";
export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn"; export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn";
export type ChatContextType = "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "edit_resume" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match"; export type ChatContextType = "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_chat" | "resume_review" | "edit_resume" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match";
export type ChatSenderType = "user" | "assistant" | "system" | "information" | "warning" | "error"; export type ChatSenderType = "user" | "assistant" | "system" | "information" | "warning" | "error";
@ -284,7 +284,7 @@ export interface Certification {
} }
export interface ChatContext { export interface ChatContext {
type: "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "edit_resume" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match"; type: "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_chat" | "resume_review" | "edit_resume" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match";
relatedEntityId?: string; relatedEntityId?: string;
relatedEntityType?: "job" | "candidate" | "employer"; relatedEntityType?: "job" | "candidate" | "employer";
additionalContext?: Record<string, any>; additionalContext?: Record<string, any>;

View File

@ -0,0 +1,11 @@
const formatDate = (date: Date | undefined, isMobile = false, isSmall = false): string => {
if (!date) return 'N/A';
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
...(isMobile ? {} : { year: 'numeric' }),
...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' }),
}).format(date);
};
export { formatDate };

View File

@ -185,6 +185,7 @@ class ChatContextType(str, Enum):
JOB_REQUIREMENTS = "job_requirements" JOB_REQUIREMENTS = "job_requirements"
CANDIDATE_CHAT = "candidate_chat" CANDIDATE_CHAT = "candidate_chat"
INTERVIEW_PREP = "interview_prep" INTERVIEW_PREP = "interview_prep"
RESUME_CHAT = "resume_chat"
RESUME_REVIEW = "resume_review" RESUME_REVIEW = "resume_review"
EDIT_RESUME = "edit_resume" EDIT_RESUME = "edit_resume"
GENERAL = "general" GENERAL = "general"

View File

@ -72,6 +72,9 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
self.md = MarkItDown(enable_plugins=False) # Set to True to enable plugins self.md = MarkItDown(enable_plugins=False) # Set to True to enable plugins
self.processing_lock = asyncio.Lock() self.processing_lock = asyncio.Lock()
self.processing_debounce = {} # Add this line
self.debounce_delay = 1.0 # seconds
# self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2') # self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
# Path for storing file hash state # Path for storing file hash state
@ -220,7 +223,17 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
return files_processed return files_processed
async def process_file_update(self, file_path): async def process_file_update(self, file_path):
"""Process a file update event.""" """Process a file update event, debounced to debounce_delay."""
# Debouncing logic
current_time = asyncio.get_event_loop().time()
if file_path in self.processing_debounce:
time_since_last = current_time - self.processing_debounce[file_path]
if time_since_last < self.debounce_delay:
logging.info(f"Debouncing {file_path} (last processed {time_since_last:.2f}s ago)")
return
self.processing_debounce[file_path] = current_time
# Use a lock to make the check-and-add atomic # Use a lock to make the check-and-add atomic
async with self.processing_lock: async with self.processing_lock:
@ -240,15 +253,16 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
if not current_hash: if not current_hash:
return return
# Use the update_lock to make hash check and update atomic
async with self.update_lock:
if file_path in self.file_hashes and self.file_hashes[file_path] == current_hash: if file_path in self.file_hashes and self.file_hashes[file_path] == current_hash:
logging.info(f"Hash has not changed for {file_path}") logging.info(f"Hash has not changed for {file_path}")
return return
# Update file hash # Update file hash BEFORE processing to prevent race conditions
self.file_hashes[file_path] = current_hash self.file_hashes[file_path] = current_hash
# Process and update the file in ChromaDB # Process and update the file in ChromaDB
async with self.update_lock:
await self._update_document_in_collection(file_path) await self._update_document_in_collection(file_path)
# Save the hash state after successful update # Save the hash state after successful update
@ -488,23 +502,31 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
logging.error(f"Error hashing file {file_path}: {e}") logging.error(f"Error hashing file {file_path}: {e}")
return None return None
def _should_process_file(self, file_path):
"""Check if a file should be processed."""
# Skip temporary files, hidden files, etc.
file_name = os.path.basename(file_path)
if file_name.startswith(".") or file_name.endswith(".tmp"):
return False
# Add other filtering logic as needed
return True
def on_modified(self, event): def on_modified(self, event):
"""Handle file modification events.""" """Handle file modification events."""
if event.is_directory: if event.is_directory or not self._should_process_file(event.src_path):
return return
file_path = event.src_path file_path = event.src_path
# Schedule the update using asyncio
asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop) asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop)
logging.info(f"File modified: {file_path}") logging.info(f"File modified: {file_path}")
def on_created(self, event): def on_created(self, event):
"""Handle file creation events.""" """Handle file creation events."""
if event.is_directory: if event.is_directory or not self._should_process_file(event.src_path):
return return
file_path = event.src_path file_path = event.src_path
# Schedule the update using asyncio
asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop) asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop)
logging.info(f"File created: {file_path}") logging.info(f"File created: {file_path}")
@ -550,13 +572,13 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
if file_path.endswith(extensions): if file_path.endswith(extensions):
p = Path(file_path) p = Path(file_path)
p_as_md = p.with_suffix(".md") p_as_md = p.with_suffix(".md")
if p_as_md.exists():
logging.info(f"newer: {p.stat().st_mtime > p_as_md.stat().st_mtime}")
# If file_path.md doesn't exist or file_path is newer than file_path.md, # If file_path.md doesn't exist or file_path is newer than file_path.md,
# fire off markitdown # fire off markitdown
if (not p_as_md.exists()) or (p.stat().st_mtime > p_as_md.stat().st_mtime): if (not p_as_md.exists()) or (p.stat().st_mtime > p_as_md.stat().st_mtime):
self._markitdown(file_path, p_as_md) self._markitdown(file_path, p_as_md)
# Add the generated .md file to processing_files to prevent double-processing
self.processing_files.add(str(p_as_md))
return return
chunks = self._markdown_chunker.process_file(file_path) chunks = self._markdown_chunker.process_file(file_path)

View File

@ -147,11 +147,17 @@ async def get_resume(
): ):
"""Get a specific resume by ID""" """Get a specific resume by ID"""
try: try:
resume = await database.get_resume(current_user.id, resume_id) resume_data = await database.get_resume(current_user.id, resume_id)
if not resume: if not resume_data:
raise HTTPException(status_code=404, detail="Resume not found") raise HTTPException(status_code=404, detail="Resume not found")
resume = Resume.model_validate(resume_data)
return {"success": True, "resume": resume} job_data = await database.get_job(resume.job_id)
if job_data:
resume.job = Job.model_validate(job_data)
candidate_data = await database.get_candidate(resume.candidate_id)
if candidate_data:
resume.candidate = Candidate.model_validate(candidate_data)
return create_success_response(resume.model_dump(by_alias=True))
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e: