From 574d040492d1903f6bdd38173eac15bfa22c310f Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Wed, 16 Jul 2025 16:12:10 -0700 Subject: [PATCH] Added chat/resume --- Dockerfile | 92 +-- docker-compose.yml | 42 +- frontend/src/components/DiffViewer.css | 20 +- frontend/src/components/DiffViewer.tsx | 65 +- frontend/src/components/Message.tsx | 2 +- frontend/src/components/ResumeChat.tsx | 160 +---- frontend/src/components/Scrollable.tsx | 2 + .../src/components/layout/BackstoryLayout.tsx | 10 +- frontend/src/components/ui/CandidateInfo.tsx | 6 +- frontend/src/components/ui/ResumeInfo.css | 152 ----- frontend/src/components/ui/ResumeInfo.tsx | 623 +++--------------- frontend/src/components/ui/ResumeViewer.tsx | 17 +- frontend/src/config/navigationConfig.tsx | 3 +- frontend/src/pages/CandidateChatPage.tsx | 298 +++++---- frontend/src/services/api-client.ts | 4 +- frontend/src/types/types.ts | 6 +- frontend/src/utils/formatDate.tsx | 11 + src/backend/models.py | 1 + src/backend/rag/rag.py | 52 +- src/backend/routes/resumes.py | 14 +- 20 files changed, 498 insertions(+), 1082 deletions(-) delete mode 100644 frontend/src/components/ui/ResumeInfo.css create mode 100644 frontend/src/utils/formatDate.tsx diff --git a/Dockerfile b/Dockerfile index af44e6f..299b491 100644 --- a/Dockerfile +++ b/Dockerfile @@ -594,68 +594,68 @@ ENV PATH=/opt/backstory:$PATH 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 add-apt-repository ppa:deadsnakes/ppa \ - && apt-get update \ - && apt-get install -y python3.10 net-tools -RUN ln -sf /usr/bin/python3.10 /usr/bin/python3 +# RUN apt-get update && apt install -y software-properties-common libtbb-dev +# RUN add-apt-repository ppa:deadsnakes/ppa \ +# && apt-get update \ +# && apt-get install -y python3.10 net-tools +# RUN ln -sf /usr/bin/python3.10 /usr/bin/python3 -RUN apt-get install -y ca-certificates git wget curl gcc g++ \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +# RUN apt-get install -y ca-certificates git wget curl gcc g++ \ +# && apt-get clean \ +# && rm -rf /var/lib/apt/lists/* -WORKDIR /home/ollama_ov_server -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 -ENV PATH=/usr/local/go/bin:$PATH +# WORKDIR /home/ollama_ov_server +# 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 +# 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 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 +# 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 +# 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 GODEBUG=cgocheck=0 +# ENV CGO_ENABLED=1 +# ENV GODEBUG=cgocheck=0 -ENV CGO_LDFLAGS=-L$GENAI_DIR/runtime/lib/intel64 -ENV CGO_CFLAGS=-I$GENAI_DIR/runtime/include +# ENV CGO_LDFLAGS=-L$GENAI_DIR/runtime/lib/intel64 +# ENV CGO_CFLAGS=-I$GENAI_DIR/runtime/include -WORKDIR /home/ollama_ov_server -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 +# RUN git clone https://github.com/openvinotoolkit/openvino_contrib.git +# 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 -EXPOSE 11434 +# ENV OLLAMA_HOST=0.0.0.0:11434 +# EXPOSE 11434 -RUN apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y \ - pip \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} +# RUN apt-get update \ +# && DEBIAN_FRONTEND=noninteractive apt-get install -y \ +# pip \ +# && apt-get clean \ +# && 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-8B-int4-ov -- didn't work -#RUN huggingface-cli download OpenVINO/${model} -#RUN modelscope download --model OpenVINO/${model} --local_dir ./${model} +# #ENV model=Qwen3-4B-int4-ov +# #ENV model=Qwen3-8B-int4-ov -- didn't work +# #RUN huggingface-cli download OpenVINO/${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 { \ -# echo "FROM ${model}.tar.gz" ; \ -# echo "ModelType 'OpenVINO'" ; \ -#} > /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 tar -zcvf /root/.ollama/models/${model}.tar.gz /root/.cache/hub/models--OpenVINO--${model} +# #RUN { \ +# # echo "FROM ${model}.tar.gz" ; \ +# # echo "ModelType 'OpenVINO'" ; \ +# #} > /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 diff --git a/docker-compose.yml b/docker-compose.yml index 3a4ef09..2d04166 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -188,27 +188,27 @@ services: - CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN) - CAP_SYS_PTRACE # PTRACE_MODE_READ_REALCREDS ptrace access mode check - ollama-ov-server: - build: - context: . - dockerfile: Dockerfile - target: ollama-ov-server - container_name: ollama-ov-server - restart: "no" - env_file: - - .env - environment: - - OLLAMA_HOST=0.0.0.0 - - ONEAPI_DEVICE_SELECTOR=level_zero:0 - devices: - - /dev/dri:/dev/dri - ports: - - 11435:11434 # ollama serve port - networks: - - internal - volumes: - - ./cache:/root/.cache # Cache hub models and neo_compiler_cache - - ./ollama:/root/.ollama # Cache the ollama models + # ollama-ov-server: + # build: + # context: . + # dockerfile: Dockerfile + # target: ollama-ov-server + # container_name: ollama-ov-server + # restart: "no" + # env_file: + # - .env + # environment: + # - OLLAMA_HOST=0.0.0.0 + # - ONEAPI_DEVICE_SELECTOR=level_zero:0 + # devices: + # - /dev/dri:/dev/dri + # ports: + # - 11435:11434 # ollama serve port + # networks: + # - internal + # volumes: + # - ./cache:/root/.cache # Cache hub models and neo_compiler_cache + # - ./ollama:/root/.ollama # Cache the ollama models vllm: build: diff --git a/frontend/src/components/DiffViewer.css b/frontend/src/components/DiffViewer.css index 9c47f5a..cc7b283 100644 --- a/frontend/src/components/DiffViewer.css +++ b/frontend/src/components/DiffViewer.css @@ -1,9 +1,21 @@ .d2h-file-side-diff .d2h-code-line pre { - white-space: pre-wrap !important; - word-wrap: break-word !important; - overflow-wrap: break-word !important; + white-space: pre-wrap !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; } .d2h-file-header { display: none; -} \ No newline at end of file +} + +.d2h-code-line { + display: flex; + flex-direction: row; + max-width: 100%; +} + +.d2h-code-line > span { + display: inline-flex; + max-width: 100%; + white-space: wrap; +} diff --git a/frontend/src/components/DiffViewer.tsx b/frontend/src/components/DiffViewer.tsx index 71563c3..2e3ef2a 100644 --- a/frontend/src/components/DiffViewer.tsx +++ b/frontend/src/components/DiffViewer.tsx @@ -4,21 +4,42 @@ import { Box, SxProps } from '@mui/material'; import { Diff2HtmlUI } from 'diff2html/lib/ui/js/diff2html-ui'; import 'diff2html/bundles/css/diff2html.min.css'; import './DiffViewer.css'; +// import { Scrollable } from './Scrollable'; + +interface DiffFile { + content: string; + name: string; +} interface DiffViewerProps { - original: string; - content: string; + original: DiffFile; + modified: DiffFile; + changeLog: string; sx?: SxProps; outputFormat?: 'line-by-line' | 'side-by-side'; drawFileList?: boolean; } const DiffViewer: React.FC = (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(null); - const diffString = createPatch('Resume', original || '', content || '', 'original', 'modified', { - context: 5, - }); + const diffString = createPatch( + 'Resume', + original.content || '', + modified.content || '', + original.name, + modified.name, + { + context: 5, + } + ); useEffect(() => { if (diffRef.current && diffString) { @@ -29,7 +50,7 @@ const DiffViewer: React.FC = (props: DiffViewerProps) => { const diff2htmlUi = new Diff2HtmlUI(diffRef.current, diffString, { drawFileList: false, matching: 'lines', - outputFormat: 'side-by-side', + outputFormat, synchronisedScroll: true, highlight: true, fileContentToggle: false, @@ -44,17 +65,33 @@ const DiffViewer: React.FC = (props: DiffViewerProps) => { return ( + > + {changeLog} + + ); }; diff --git a/frontend/src/components/Message.tsx b/frontend/src/components/Message.tsx index 76f4201..c3e400d 100644 --- a/frontend/src/components/Message.tsx +++ b/frontend/src/components/Message.tsx @@ -521,7 +521,7 @@ const Message = (props: MessageProps): JSX.Element => { let metadataView = <>; let metadata: ChatMessageMetaData | 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; } if (metadata) { diff --git a/frontend/src/components/ResumeChat.tsx b/frontend/src/components/ResumeChat.tsx index ab67759..09511f8 100644 --- a/frontend/src/components/ResumeChat.tsx +++ b/frontend/src/components/ResumeChat.tsx @@ -1,23 +1,9 @@ import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react'; -import { - Box, - Button, - Tooltip, - SxProps, - Typography, - Tabs, - Tab, - Dialog, - DialogContent, -} from '@mui/material'; +import { Box, Button, Tooltip, SxProps, Typography } from '@mui/material'; import { Send as SendIcon, Person as PersonIcon } from '@mui/icons-material'; -// import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing'; -import SaveIcon from '@mui/icons-material/Save'; -import UndoIcon from '@mui/icons-material/Undo'; +import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing'; +import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer'; 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 { ChatMessage, ChatSession, @@ -35,7 +21,6 @@ import PropagateLoader from 'react-spinners/PropagateLoader'; import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField'; import { Scrollable } from 'components/Scrollable'; import { StyledMarkdown } from './StyledMarkdown'; -import { DiffViewer } from './DiffViewer'; const emptyMetadata: ChatMessageMetaData = { model: 'qwen2.5', @@ -64,126 +49,12 @@ const defaultMessage: ChatMessage = { }; interface ResumeChatProps { - onResumeChange: (resume: string) => void; // Callback when the resume changes + onResumeChange: (prompt: string, resume: string) => void; // Callback when the resume changes resume: string; session: string; // Session identifier for the chat 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('raw'); - const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => { - if (newValue === 'diff') { - setTabValue(newValue); - } else { - setTabValue(newValue); - } - }; - - return ( - - - } sx={{ height: 20 }} /> - } sx={{ height: 20 }} /> - } sx={{ height: 20 }} /> - - - {tabValue === 'raw' && ( - -
{content}
-
- )} - {tabValue === 'markdown' && } - { - setTabValue('raw'); - }} - maxWidth="lg" - fullWidth - disableEscapeKeyDown={false} - fullScreen={true} - > - - - - - - - - - - - - - - - - - - -
- ); -}; - const ResumeChat = forwardRef( (props: ResumeChatProps, ref): JSX.Element => { const { @@ -215,6 +86,7 @@ const ResumeChat = forwardRef( const [loading, setLoading] = useState(false); const [streaming, setStreaming] = useState(false); const messagesEndRef = useRef(null); + const [lastPrompt, setLastPrompt] = useState(''); const onDelete = async (session: ChatSession): Promise => { if (!session.id) { @@ -237,6 +109,7 @@ const ResumeChat = forwardRef( const messageContent = message; setStreaming(true); + setLastPrompt(message); const chatMessage: ChatMessageUser = { sessionId: chatSession.id, @@ -267,6 +140,8 @@ const ResumeChat = forwardRef( msg.extraContext = { isAnswer: true, }; + } else { + onResumeChange(lastPrompt, msg.content); } setMessages(prev => { @@ -424,23 +299,22 @@ const ResumeChat = forwardRef( > {message.role === 'user' && ( <> - + {message.content} )} {message.role === 'assistant' && ( <> {message.extraContext?.isAnswer ? ( - + <> + + + ) : ( - { - onResumeChange && onResumeChange(content); - }} - sx={{ maxWidth: '100%', boxSizing: 'border-box' }} - /> + <> + + + )} )} diff --git a/frontend/src/components/Scrollable.tsx b/frontend/src/components/Scrollable.tsx index 402d946..3d21107 100644 --- a/frontend/src/components/Scrollable.tsx +++ b/frontend/src/components/Scrollable.tsx @@ -34,6 +34,8 @@ const Scrollable = forwardRef((props: ScrollableProps, ref) => { flexGrow: 1, overflow: 'auto', position: 'relative', + maxHeight: '100%', + minHeight: 0, // Ensure it can shrink to fit content // backgroundColor: '#F5F5F5', ...sx, }} diff --git a/frontend/src/components/layout/BackstoryLayout.tsx b/frontend/src/components/layout/BackstoryLayout.tsx index bc461f9..3752b19 100644 --- a/frontend/src/components/layout/BackstoryLayout.tsx +++ b/frontend/src/components/layout/BackstoryLayout.tsx @@ -3,7 +3,6 @@ import React, { JSX, ReactElement, useEffect, useState } from 'react'; import { Outlet, useLocation, Routes, Route, matchPath } from 'react-router-dom'; import { Box, Container, Paper } from '@mui/material'; import { useNavigate } from 'react-router-dom'; -import { SxProps, Theme } from '@mui/material'; import { darken } from '@mui/material/styles'; import { Header } from 'components/layout/Header'; import { Scrollable } from 'components/Scrollable'; @@ -23,12 +22,11 @@ export type NavigationLinkType = { interface BackstoryPageContainerProps { children?: React.ReactNode; - sx?: SxProps; variant?: 'normal' | 'fullWidth'; } const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element => { - const { children, sx, variant = 'normal' } = props; + const { children, variant = 'normal' } = props; console.log({ variant }); return ( = (props: CandidateInfoProps) {candidate.location && ( - Location: {candidate.location.city},{' '} - {candidate.location.state || candidate.location.country} + Location:{' '} + {candidate.location.city + ? `${candidate.location.city}, ${candidate.location.state}` + : candidate.location.text} )} {candidate.email && ( diff --git a/frontend/src/components/ui/ResumeInfo.css b/frontend/src/components/ui/ResumeInfo.css deleted file mode 100644 index dd660f1..0000000 --- a/frontend/src/components/ui/ResumeInfo.css +++ /dev/null @@ -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; -} - diff --git a/frontend/src/components/ui/ResumeInfo.tsx b/frontend/src/components/ui/ResumeInfo.tsx index c00cbef..ab440ab 100644 --- a/frontend/src/components/ui/ResumeInfo.tsx +++ b/frontend/src/components/ui/ResumeInfo.tsx @@ -24,13 +24,17 @@ import { Select, MenuItem, InputLabel, - Theme, Chip, Alert, Stack, SelectChangeEvent, } from '@mui/material'; 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 { Delete as DeleteIcon, Restore as RestoreIcon, @@ -41,19 +45,15 @@ import { Person as PersonIcon, Schedule as ScheduleIcon, ModelTraining, - Email as EmailIcon, - Phone as PhoneIcon, - LocationOn as LocationIcon, History as HistoryIcon, RestoreFromTrash as RestoreFromTrashIcon, Refresh as RefreshIcon, + PrecisionManufacturing, // Language as WebsiteIcon, } from '@mui/icons-material'; -import InputIcon from '@mui/icons-material/Input'; import TuneIcon from '@mui/icons-material/Tune'; import PreviewIcon from '@mui/icons-material/Preview'; import EditDocumentIcon from '@mui/icons-material/EditDocument'; -import { parsePhoneNumberFromString } from 'libphonenumber-js'; import { useReactToPrint } from 'react-to-print'; @@ -63,12 +63,14 @@ import { StyledMarkdown } from 'components/StyledMarkdown'; import { Resume } from 'types/types'; import { BackstoryTextField } from 'components/BackstoryTextField'; import { JobInfo } from './JobInfo'; -import './ResumeInfo.css'; import { Scrollable } from 'components/Scrollable'; import * as Types from 'types/types'; import { StreamingOptions } from 'services/api-client'; import { StatusBox, StatusIcon } from './StatusIcon'; import { ResumeChat } from 'components/ResumeChat'; +import { DiffViewer } from 'components/DiffViewer'; +import { ResumePreview, resumeStyles } from './ResumePreview'; +import { useNavigate } from 'react-router-dom'; interface ResumeInfoProps { resume: Resume; @@ -88,457 +90,9 @@ interface ResumeRevision { jobId: string; } -// Resume Style Definitions -interface ResumeStyle { - name: string; - description: string; - headerStyle: SxProps; - footerStyle: SxProps; - contentStyle: SxProps; - markdownStyle: SxProps; - 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 = generateResumeStyles(); - -// Styled Header Component -interface BackstoryStyledResumeProps { - candidate: Types.Candidate; - job?: Types.Job; - style: ResumeStyle; -} - -const StyledFooter: React.FC = ({ candidate, job, style }) => { - return ( - <> - - Dive deeper into my qualifications at Backstory... - - {candidate?.username - ? `${window.location.protocol}://${window.location.host}/u/${candidate?.username}` - : 'backstory'} - -   - - ); -}; - -const StyledHeader: React.FC = ({ candidate, style }) => { - const phone = parsePhoneNumberFromString(candidate.phone || '', 'US'); - return ( - - - - - {candidate.fullName} - - - - {candidate.description && ( - - - {candidate.description} - - - )} - - - {candidate.email && ( - - - - {candidate.email} - - - )} - - {phone?.isValid() && ( - - - - {phone.formatInternational()} - - - )} - - {candidate.location && ( - - - - {candidate.location.city - ? `${candidate.location.city}, ${candidate.location.state}` - : candidate.location.text} - - - )} - - - - - ); -}; - const ResumeInfo: React.FC = (props: ResumeInfoProps) => { const { setSnack } = useAppState(); + const navigate = useNavigate(); const { resume } = props; const { user, apiClient } = useAuth(); const { sx, variant = 'normal' } = props; @@ -551,6 +105,7 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { const [editContent, setEditContent] = useState(''); const [editSystemPrompt, setEditSystemPrompt] = useState(''); const [editPrompt, setEditPrompt] = useState(''); + const [lastRevision, setLastRevision] = useState(null); const [saving, setSaving] = useState(false); const [tabValue, setTabValue] = useState('markdown'); const [jobTabValue, setJobTabValue] = useState('chat'); @@ -630,6 +185,9 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { // Handle revision selection const handleRevisionChange = async (event: SelectChangeEvent): Promise => { const newRevisionId = event.target.value; + if (selectedRevision === 'current') { + setLastRevision(activeResume); + } setSelectedRevision(newRevisionId); await loadRevisionContent(newRevisionId); }; @@ -638,6 +196,7 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { const restoreRevision = (): void => { setEditContent(revisionContent); setSelectedRevision('current'); + setLastRevision(null); setSnack('Revision restored to editor.'); }; @@ -674,14 +233,14 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { setActiveResume({ ...resume }); }; - const handleSave = async (): Promise => { + const handleSave = async (prompt = ''): Promise => { setSaving(true); try { const resumeUpdate = { ...activeResume, resume: editContent, systemPrompt: editSystemPrompt, - prompt: editPrompt, + prompt: prompt || editPrompt, }; const result = await apiClient.updateResume(resumeUpdate); console.log('Resume updated:', result); @@ -705,6 +264,7 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { setEditSystemPrompt(activeResume.systemPrompt || ''); setEditPrompt(activeResume.prompt || ''); setSelectedRevision('current'); + setLastRevision(null); setRevisionContent(activeResume.resume); setEditDialogOpen(true); }; @@ -773,6 +333,7 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => { if (newValue === 'print') { + console.log('Printing resume...'); reactToPrintFn(); return; } @@ -878,8 +439,20 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { Job ID: {activeResume.job?.id} - - Resume ID: {activeResume.id} + + Resume ID: + { + navigate(`/chat/${activeResume.id}`); + }} + > + {activeResume.id} + @@ -1042,14 +615,12 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { } label="Markdown" /> - {activeResume.systemPrompt && ( - } label="System Prompt" /> - )} - {activeResume.systemPrompt && ( - } label="Prompt" /> - )} + {lastRevision && } label="Changes" />} } label="Preview" /> } label="Print" /> + {activeResume.systemPrompt && ( + } label="Context" /> + )} } label="Regenerate" />
@@ -1162,16 +733,29 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { {selectedRevision !== 'current' && ( - - - + <> + + + + + + + + )} @@ -1213,6 +797,9 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { flexGrow: 1, flex: 1, overflowY: 'auto', + fontFamily: 'monospace', + backgroundColor: '#fafafa', + fontSize: '12px', }} placeholder="Enter resume content..." /> @@ -1240,22 +827,8 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { )} )} - {tabValue === 'systemPrompt' && ( - setEditSystemPrompt(value)} - style={{ - position: 'relative', - maxHeight: '100%', - width: '100%', - display: 'flex', - minHeight: '100%', - flexGrow: 1, - flex: 1, - overflowY: 'auto', - }} - placeholder="Edit system prompt..." - /> + {tabValue === 'context' && ( + )} {tabValue === 'prompt' && ( = (props: ResumeInfoProps) => { placeholder="Edit prompt..." /> )} - {tabValue === 'preview' && ( + {tabValue === 'diff' && lastRevision && ( + + )} + {tabValue === 'preview' && activeResume.candidate && ( - - {/* Custom Header */} - {activeResume.candidate && ( - - )} - - {/* Styled Markdown Content */} - - - - - {/* QR Code Footer */} - {activeResume.candidate && activeResume.job && ( - - )} - + )} @@ -1363,14 +906,22 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { { - setEditContent(newResume); - setActiveResume({ ...activeResume, resume: newResume }); + onResumeChange={(prompt: string, newResume: string): void => { + if (newResume !== editContent) { + handleSave(prompt).then(() => { + setEditContent(newResume); + setLastRevision(activeResume); + setActiveResume({ ...activeResume, prompt, resume: newResume }); + }); + } }} sx={{ m: 1, p: 1, flexGrow: 1, + position: 'relative', + maxWidth: 'fit-content', + minWidth: '100%', }} /> )} @@ -1381,7 +932,9 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { */} - - {/* Chat Interface */} - {/* Scrollable Messages Area */} - {chatSession && ( - - {messages.length === 0 && } - {messages.map((message: ChatMessage) => ( - - ))} - {processingMessage !== null && ( - - )} - {streamingMessage !== null && ( - - )} - {streaming && ( - - - - )} -
- - )} - {selectedCandidate.questions?.length !== 0 && ( - - {' '} - {selectedCandidate.questions?.map((q, i) => ( - - ))} - - )} - {/* Fixed Message Input */} - - { - chatSession && onDelete(chatSession); - }} - disabled={!chatSession} - sx={{ minWidth: 'auto', px: 2, maxHeight: 'min-content' }} - action="reset" - label="chat session" - title="Reset Chat Session" - message={`Are you sure you want to reset the session? This action cannot be undone.`} - /> - - - - - - + + + + ); diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index 6ce6fb8..17de6cb 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -752,12 +752,12 @@ class ApiClient { }>(response); } - async getResume(resumeId: string): Promise<{ success: boolean; resume: Types.Resume }> { + async getResume(resumeId: string): Promise { const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, { headers: this.defaultHeaders, }); - return handleApiResponse<{ success: boolean; resume: Types.Resume }>(response); + return this.handleApiResponseWithConversion(response, 'Resume'); } async deleteResume(resumeId: string): Promise<{ success: boolean; message: string }> { diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 4934c1d..25fca30 100644 --- a/frontend/src/types/types.ts +++ b/frontend/src/types/types.ts @@ -1,6 +1,6 @@ // Generated TypeScript types from Pydantic models // 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 // ============================ @@ -19,7 +19,7 @@ export type ApiStatusType = "streaming" | "status" | "done" | "error"; 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"; @@ -284,7 +284,7 @@ export interface Certification { } 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; relatedEntityType?: "job" | "candidate" | "employer"; additionalContext?: Record; diff --git a/frontend/src/utils/formatDate.tsx b/frontend/src/utils/formatDate.tsx new file mode 100644 index 0000000..c8e45ef --- /dev/null +++ b/frontend/src/utils/formatDate.tsx @@ -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 }; diff --git a/src/backend/models.py b/src/backend/models.py index edda018..9d25ce4 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -185,6 +185,7 @@ class ChatContextType(str, Enum): JOB_REQUIREMENTS = "job_requirements" CANDIDATE_CHAT = "candidate_chat" INTERVIEW_PREP = "interview_prep" + RESUME_CHAT = "resume_chat" RESUME_REVIEW = "resume_review" EDIT_RESUME = "edit_resume" GENERAL = "general" diff --git a/src/backend/rag/rag.py b/src/backend/rag/rag.py index f9b20d4..1d28213 100644 --- a/src/backend/rag/rag.py +++ b/src/backend/rag/rag.py @@ -72,6 +72,9 @@ class ChromaDBFileWatcher(FileSystemEventHandler): self.md = MarkItDown(enable_plugins=False) # Set to True to enable plugins 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') # Path for storing file hash state @@ -220,7 +223,17 @@ class ChromaDBFileWatcher(FileSystemEventHandler): return files_processed 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 async with self.processing_lock: @@ -240,15 +253,16 @@ class ChromaDBFileWatcher(FileSystemEventHandler): if not current_hash: return - 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}") - return - - # Update file hash - self.file_hashes[file_path] = current_hash - - # Process and update the file in ChromaDB + # 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: + logging.info(f"Hash has not changed for {file_path}") + return + + # Update file hash BEFORE processing to prevent race conditions + self.file_hashes[file_path] = current_hash + + # Process and update the file in ChromaDB await self._update_document_in_collection(file_path) # Save the hash state after successful update @@ -488,23 +502,31 @@ class ChromaDBFileWatcher(FileSystemEventHandler): logging.error(f"Error hashing file {file_path}: {e}") 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): """Handle file modification events.""" - if event.is_directory: + if event.is_directory or not self._should_process_file(event.src_path): return file_path = event.src_path - # Schedule the update using asyncio asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop) logging.info(f"File modified: {file_path}") def on_created(self, event): """Handle file creation events.""" - if event.is_directory: + if event.is_directory or not self._should_process_file(event.src_path): return file_path = event.src_path - # Schedule the update using asyncio asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop) logging.info(f"File created: {file_path}") @@ -550,13 +572,13 @@ class ChromaDBFileWatcher(FileSystemEventHandler): if file_path.endswith(extensions): p = Path(file_path) 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, # fire off markitdown if (not p_as_md.exists()) or (p.stat().st_mtime > p_as_md.stat().st_mtime): 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 chunks = self._markdown_chunker.process_file(file_path) diff --git a/src/backend/routes/resumes.py b/src/backend/routes/resumes.py index 1fd307f..6ed3f89 100644 --- a/src/backend/routes/resumes.py +++ b/src/backend/routes/resumes.py @@ -147,11 +147,17 @@ async def get_resume( ): """Get a specific resume by ID""" try: - resume = await database.get_resume(current_user.id, resume_id) - if not resume: + resume_data = await database.get_resume(current_user.id, resume_id) + if not resume_data: raise HTTPException(status_code=404, detail="Resume not found") - - return {"success": True, "resume": resume} + resume = Resume.model_validate(resume_data) + 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: raise except Exception as e: