From 9f7ddca90a1a3c2620629d86f2345a58227a2761 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Wed, 14 May 2025 16:17:27 -0700 Subject: [PATCH] Routing refactored --- Dockerfile | 114 ++- README.md | 4 +- docker-compose.yml | 44 +- frontend/package-lock.json | 50 ++ frontend/package.json | 1 + frontend/public/docs/about.md | 10 +- frontend/src/App.tsx | 426 +-------- frontend/src/BackstoryTab.tsx | 2 +- frontend/src/ChatQuery.tsx | 30 +- frontend/src/ControlsPage.tsx | 5 +- frontend/src/Conversation.tsx | 85 +- frontend/src/Document.tsx | 15 +- frontend/src/HomePage.tsx | 8 +- frontend/src/{App.css => Main.css} | 0 frontend/src/Main.tsx | 301 +++++++ frontend/src/Message.tsx | 11 +- frontend/src/ResumeBuilderPage.tsx | 58 +- frontend/src/SessionWrapper.tsx | 65 ++ frontend/src/StyledMarkdown.tsx | 25 +- frontend/src/VectorVisualizer.tsx | 2 +- src/pyproject.toml | 22 + src/requirements.txt | 276 ++---- src/server.py | 62 +- .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 145 bytes .../__pycache__/test-agent.cpython-311.pyc | Bin 0 -> 1745 bytes .../__pycache__/test-chunker.cpython-311.pyc | Bin 0 -> 996 bytes .../test-context-routing.cpython-311.pyc | Bin 0 -> 10272 bytes .../__pycache__/test-context.cpython-311.pyc | Bin 0 -> 1129 bytes .../__pycache__/test-message.cpython-311.pyc | Bin 0 -> 507 bytes .../__pycache__/test-metrics.cpython-311.pyc | Bin 0 -> 831 bytes src/tests/test-chunker.py | 14 + src/tests/test.md | 19 + src/utils/agents/base.py | 2 +- src/utils/agents/job_description.py | 807 +----------------- src/utils/context.py | 1 + src/utils/conversation.py | 2 +- src/utils/markdown_chunker.py | 212 +++++ src/utils/message.py | 2 - src/utils/metrics.py | 3 +- src/utils/rag.py | 2 +- 40 files changed, 1062 insertions(+), 1618 deletions(-) rename frontend/src/{App.css => Main.css} (100%) create mode 100644 frontend/src/Main.tsx create mode 100644 frontend/src/SessionWrapper.tsx create mode 100644 src/pyproject.toml create mode 100644 src/tests/__pycache__/__init__.cpython-311.pyc create mode 100644 src/tests/__pycache__/test-agent.cpython-311.pyc create mode 100644 src/tests/__pycache__/test-chunker.cpython-311.pyc create mode 100644 src/tests/__pycache__/test-context-routing.cpython-311.pyc create mode 100644 src/tests/__pycache__/test-context.cpython-311.pyc create mode 100644 src/tests/__pycache__/test-message.cpython-311.pyc create mode 100644 src/tests/__pycache__/test-metrics.cpython-311.pyc create mode 100644 src/tests/test-chunker.py create mode 100644 src/tests/test.md create mode 100644 src/utils/markdown_chunker.py diff --git a/Dockerfile b/Dockerfile index b972707..753e42a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -154,15 +154,6 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} -# The client frontend is built using React Expo to allow -# easy creation of an Android app as well as web app -RUN apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y \ - nodejs \ - npm \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} - # Install Intel graphics runtimes RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y software-properties-common \ @@ -215,8 +206,8 @@ RUN pip install intel-extension-for-pytorch==2.6.10+xpu oneccl_bind_pt==2.6.0+xp # From https://huggingface.co/docs/bitsandbytes/main/en/installation?backend=Intel+CPU+%2B+GPU#multi-backend RUN pip install "transformers>=4.45.1" -RUN pip install 'https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_multi-backend-refactor/bitsandbytes-0.44.1.dev0-py3-none-manylinux_2_24_x86_64.whl' - +#RUN pip install 'https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_multi-backend-refactor/bitsandbytes-0.44.1.dev0-py3-none-manylinux_2_24_x86_64.whl' +RUN pip install 'https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_multi-backend-refactor/bitsandbytes-0.45.3.dev272-py3-none-manylinux_2_24_x86_64.whl' # Install ollama python module RUN pip install ollama langchain-ollama @@ -247,16 +238,19 @@ RUN pip3 install 'bigdl-core-xe-all>=2.6.0b' # NOTE: IPEX includes the oneAPI components... not sure if they still need to be installed separately with a oneAPI env RUN pip install einops diffusers # Required for IPEX optimize(), which is required to convert from Params4bit -# Needed by src/utils/chroma.py +# Needed by src/utils/rag.py RUN pip install watchdog -# Install packages needed for stock.py -RUN pip install yfinance pyzt geopy PyHyphen nltk +# Install packages needed for utils/tools/* +RUN pip install yfinance pyzt geopy + +# Install packages needed for vector operations +RUN pip install umap-learn FROM llm-base AS backstory -COPY /src/requirements.txt /opt/backstory/src/requirements.txt -RUN pip install -r /opt/backstory/src/requirements.txt +#COPY /src/requirements.txt /opt/backstory/src/requirements.txt +#RUN pip install -r /opt/backstory/src/requirements.txt RUN pip install 'markitdown[all]' pydantic # Prometheus @@ -283,14 +277,6 @@ RUN { \ echo ' exec /bin/bash'; \ echo ' fi' ; \ echo 'else'; \ - echo ' if [[ "${PRODUCTION}" -eq 0 ]]; then'; \ - echo ' while true; do'; \ - echo ' cd /opt/backstory/frontend'; \ - echo ' echo "Launching Backstory React Frontend..."'; \ - echo ' npm start "${@}" || echo "Backstory frontend died. Restarting in 3 seconds."'; \ - echo ' sleep 3'; \ - echo ' done &' ; \ - echo ' fi' ; \ echo ' if [[ ! -e src/cert.pem ]]; then' ; \ echo ' echo "Generating self-signed certificate for HTTPS"'; \ echo ' openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout src/key.pem -out src/cert.pem -subj "/C=US/ST=OR/L=Portland/O=Development/CN=localhost"'; \ @@ -320,11 +306,6 @@ ENV SYCL_CACHE_PERSISTENT=1 ENV PATH=/opt/backstory:$PATH COPY /src/ /opt/backstory/src/ -COPY /frontend/ /opt/backstory/frontend/ - -WORKDIR /opt/backstory/frontend -RUN npm install --force -WORKDIR /opt/backstory ENTRYPOINT [ "/entrypoint.sh" ] @@ -454,6 +435,14 @@ ENTRYPOINT [ "/entrypoint.sh" ] FROM llm-base AS jupyter +# npm and Node.JS are required for jupyterlab +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} + SHELL [ "/opt/backstory/shell" ] # BEGIN setup Jupyter @@ -463,9 +452,8 @@ RUN pip install \ && jupyter lab build --dev-build=False --minimize=False # END setup Jupyter -COPY /src/requirements.txt /opt/backstory/src/requirements.txt - -RUN pip install -r /opt/backstory/src/requirements.txt +#COPY /src/requirements.txt /opt/backstory/src/requirements.txt +#RUN pip install -r /opt/backstory/src/requirements.txt RUN pip install timm xformers @@ -572,3 +560,65 @@ RUN { \ && chmod +x /entrypoint.sh ENTRYPOINT [ "/entrypoint.sh" ] + +FROM ubuntu:oracular AS frontend + +# The client frontend is built using React Expo to allow +# easy creation of an Android app as well as web app +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} + +SHELL [ "/bin/bash", "-c" ] + +RUN { \ + echo '#!/bin/bash'; \ + echo 'echo "Container: frontend"'; \ + echo 'set -e'; \ + echo ''; \ + echo 'if [[ "${1}" == "/bin/bash" ]] || [[ "${1}" =~ ^(/opt/backstory/)?shell$ ]]; then'; \ + echo ' echo "Dropping to shell"'; \ + echo ' shift' ; \ + echo ' echo "Running: ${@}"' ; \ + echo ' if [[ "${1}" != "" ]]; then' ; \ + echo ' exec ${@}'; \ + echo ' else' ; \ + echo ' exec /bin/bash'; \ + echo ' fi' ; \ + echo 'fi' ; \ + echo 'cd /opt/backstory/frontend'; \ + echo 'if [[ "${1}" == "install" ]] || [[ ! -d node_modules ]]; then'; \ + echo ' echo "Installing node modules"'; \ + echo ' if [[ -d node_modules ]]; then'; \ + echo ' echo "Deleting current node_modules"'; \ + echo ' rm -rf node_modules'; \ + echo ' fi'; \ + echo ' npm install --force'; \ + echo 'fi'; \ + echo 'if [[ "${1}" == "build" ]]; then'; \ + echo ' echo "Building production static build"'; \ + echo ' ./build.sh'; \ + echo 'fi'; \ + echo 'while true; do'; \ + echo ' echo "Launching Backstory React Frontend..."'; \ + echo ' npm start "${@}" || echo "Backstory frontend died. Restarting in 3 seconds."'; \ + echo ' sleep 3'; \ + echo 'done' ; \ + } > /entrypoint.sh \ + && chmod +x /entrypoint.sh + +WORKDIR /opt/backstory/frontend + +RUN { \ + echo '#!/bin/bash' ; \ + echo 'if [[ "${1}" != "" ]]; then bash -c "${@}"; else bash; fi' ; \ + } > /opt/backstory/shell ; \ + chmod +x /opt/backstory/shell + +COPY /frontend/ /opt/backstory/frontend/ +ENV PATH=/opt/backstory:$PATH + +ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/README.md b/README.md index 2b3841b..6dbda34 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,9 @@ This project provides the following containers: | Container | Purpose | |:----------|:---------------------------------------------------------------| -| backstory | Base container with GPU packages installed and configured. Main server entry point. Also used for frontend development. | +| backstory | Base container with GPU packages installed and configured. Main server entry point. Exposes an HTTPS entrypoint for use by frontend development | +| backstory-prod | Base container with GPU packages installed and configured. Main server entry point. Exposes an HTTP entrypoint for exposing via nginx or other reverse proxy server. Serves static files generated by frontend. | +| frontend | Frontend development and building static file for backstory-prod. | | jupyter | backstory + Jupyter notebook for running Jupyter sessions | | miniircd | Tiny deployment of an IRC server for testing IRC agents | | ollama | Installation of Intel's pre-built Ollama.cpp | diff --git a/docker-compose.yml b/docker-compose.yml index 1353baa..3a050a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: target: backstory container_name: backstory image: backstory - restart: "no" + restart: "always" env_file: - .env environment: @@ -19,8 +19,7 @@ services: networks: - internal ports: - - 8912:8911 # Flask React server - - 3000:3000 # REACT expo while developing frontend + - 8912:8911 # FastAPI React server volumes: - ./cache:/root/.cache # Persist all models and GPU kernel cache - ./sessions:/opt/backstory/sessions:rw # Persist sessions @@ -28,7 +27,6 @@ services: - ./dev-keys:/opt/backstory/keys:ro # Developer keys - ./docs:/opt/backstory/docs:rw # Live mount of RAG content - ./src:/opt/backstory/src:rw # Live mount server src - - ./frontend:/opt/backstory/frontend:rw # Live mount frontend src cap_add: # used for running ze-monitor within container - CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks - CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN) @@ -54,18 +52,33 @@ services: networks: - internal ports: - - 8911:8911 # Flask React server + - 8911:8911 # FastAPI React server volumes: - ./cache:/root/.cache # Persist all models and GPU kernel cache - ./chromadb-prod:/opt/backstory/chromadb:rw # Persist ChromaDB - ./sessions-prod:/opt/backstory/sessions:rw # Persist sessions - ./docs-prod:/opt/backstory/docs:rw # Live mount of RAG content - - ./frontend:/opt/backstory/frontend:rw # Live mount frontend src + - ./frontend/deployed:/opt/backstory/deployed:ro # Live mount built frontend cap_add: # used for running ze-monitor within container - CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks - CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN) - CAP_SYS_PTRACE # PTRACE_MODE_READ_REALCREDS ptrace access mode check + frontend: + build: + context: . + dockerfile: Dockerfile + target: frontend + container_name: frontend + image: frontend + restart: "always" + env_file: + - .env + ports: + - 3000:3000 # REACT expo while developing frontend + volumes: + - ./frontend:/opt/backstory/frontend:rw # Live mount frontend src + ollama: build: context: . @@ -116,19 +129,6 @@ services: volumes: - ./jupyter:/opt/jupyter:rw - ./cache:/root/.cache - deploy: - resources: - limits: - memory: "0" # No memory limit (Docker treats 0 as unlimited) - reservations: - memory: "0" # No reserved memory (optional) - ulimits: - memlock: -1 # Prevents memory from being locked - #oom_kill_disable: true # Prevents OOM killer from killing the container - cap_add: # used for running ze-monitor within container - - CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks - - CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN) - - CAP_SYS_PTRACE # PTRACE_MODE_READ_REALCREDS ptrace access mode check miniircd: build: @@ -140,8 +140,6 @@ services: restart: "no" env_file: - .env - devices: - - /dev/dri:/dev/dri ports: - 6667:6667 # IRC networks: @@ -153,10 +151,6 @@ services: image: prom/prometheus container_name: prometheus restart: "always" -# env_file: -# - .env -# devices: -# - /dev/dri:/dev/dri ports: - 9090:9090 # Prometheus networks: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d88a348..0f9f562 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,6 +32,7 @@ "react-dom": "^19.0.0", "react-markdown": "^10.1.0", "react-plotly.js": "^2.6.0", + "react-router-dom": "^7.6.0", "react-scripts": "5.0.1", "react-spinners": "^0.15.0", "rehype-katex": "^7.0.1", @@ -18629,6 +18630,50 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", + "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz", + "integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==", + "dependencies": { + "react-router": "7.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", @@ -19781,6 +19826,11 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index eed0df5..316506f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "react-dom": "^19.0.0", "react-markdown": "^10.1.0", "react-plotly.js": "^2.6.0", + "react-router-dom": "^7.6.0", "react-scripts": "5.0.1", "react-spinners": "^0.15.0", "rehype-katex": "^7.0.1", diff --git a/frontend/public/docs/about.md b/frontend/public/docs/about.md index 5d4e904..45fb668 100644 --- a/frontend/public/docs/about.md +++ b/frontend/public/docs/about.md @@ -19,10 +19,16 @@ ## Some questions I've been asked -Q. +Q. A. I could; but I don't want to store your data. I also don't want to have to be on the hook for support of this service. I like it, it's fun, but it's not what I want as my day-gig, you know? If it was, I wouldn't be looking for a job... -Q. +Q. A. Try it. See what you find out :) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 003889b..7f503b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,402 +1,40 @@ -import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import useMediaQuery from '@mui/material/useMediaQuery'; -import Card from '@mui/material/Card'; -import { styled } from '@mui/material/styles'; -import Avatar from '@mui/material/Avatar'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import Tooltip from '@mui/material/Tooltip'; -import AppBar from '@mui/material/AppBar'; -import Drawer from '@mui/material/Drawer'; -import Toolbar from '@mui/material/Toolbar'; -import SettingsIcon from '@mui/icons-material/Settings'; -import IconButton from '@mui/material/IconButton'; -import Box from '@mui/material/Box'; -import CssBaseline from '@mui/material/CssBaseline'; -import MenuIcon from '@mui/icons-material/Menu'; - +import React, { useRef, useCallback } from 'react'; +import { BrowserRouter as Router, Routes, Route, useLocation } from "react-router-dom"; +import { SessionWrapper } from "./SessionWrapper"; +import { Main } from "./Main"; import { Snack, SeverityType } from './Snack'; -import { ConversationHandle } from './Conversation'; -import { QueryOptions } from './ChatQuery'; -import { Scrollable } from './Scrollable'; -import { BackstoryPage, BackstoryTabProps } from './BackstoryTab'; -import { connectionBase } from './Global'; +export function PathRouter({ setSnack }: { setSnack: any }) { + const location = useLocation(); + const segments = location.pathname.split("/").filter(Boolean); + const sessionId = segments[segments.length - 1]; -import { HomePage } from './HomePage'; -import { ResumeBuilderPage } from './ResumeBuilderPage'; -import { VectorVisualizerPage } from './VectorVisualizer'; -import { AboutPage } from './AboutPage'; -import { ControlsPage } from './ControlsPage'; - - -import './App.css'; -import './Conversation.css'; - -import '@fontsource/roboto/300.css'; -import '@fontsource/roboto/400.css'; -import '@fontsource/roboto/500.css'; -import '@fontsource/roboto/700.css'; - - - -const isValidUUIDv4 = (str: string): boolean => { - const pattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89ab][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/i; - return pattern.test(str); + return ( + +
+ + ); } -const App = () => { - const [sessionId, setSessionId] = useState(undefined); - const [menuOpen, setMenuOpen] = useState(false); - const [isMenuClosing, setIsMenuClosing] = useState(false); - const [activeTab, setActiveTab] = useState(0); - const isDesktop = useMediaQuery('(min-width:650px)'); - const prevIsDesktopRef = useRef(isDesktop); - const chatRef = useRef(null); - const snackRef = useRef(null); - const [subRoute, setSubRoute] = useState(""); +function App() { + const snackRef = useRef(null); - useEffect(() => { - if (prevIsDesktopRef.current === isDesktop) - return; - - if (menuOpen) { - setMenuOpen(false); - } - - prevIsDesktopRef.current = isDesktop; - }, [isDesktop, setMenuOpen, menuOpen]) + const setSnack = useCallback((message: string, severity?: SeverityType) => { + snackRef.current?.setSnack(message, severity); + }, [snackRef]); - const setSnack = useCallback((message: string, severity?: SeverityType) => { - snackRef.current?.setSnack(message, severity); - }, [snackRef]); + return ( + <> + + + } /> + + + + + ); +} - const handleSubmitChatQuery = (prompt: string, tunables?: QueryOptions) => { - console.log(`handleSubmitChatQuery: ${prompt} ${tunables || {}} -- `, chatRef.current ? ' sending' : 'no handler'); - chatRef.current?.submitQuery(prompt, tunables); - setActiveTab(0); - }; - - const tabs: BackstoryTabProps[] = useMemo(() => { - const homeTab: BackstoryTabProps = { - label: "", - path: "", - tabProps: { - label: "Backstory", - sx: { flexGrow: 1, fontSize: '1rem' }, - icon: - , - iconPosition: "start" - }, - children: - }; - - const resumeBuilderTab: BackstoryTabProps = { - label: "Resume Builder", - path: "resume-builder", - children: - }; - - const contextVisualizerTab: BackstoryTabProps = { - label: "Context Visualizer", - path: "context-visualizer", - children: - }; - - const aboutTab = { - label: "About", - path: "about", - children: - }; - - const controlsTab: BackstoryTabProps = { - path: "controls", - tabProps: { - sx: { flexShrink: 1, flexGrow: 0, fontSize: '1rem' }, - icon: - }, - children: ( - - {sessionId !== undefined && - - } - - ) - }; - - return [ - homeTab, - resumeBuilderTab, - contextVisualizerTab, - aboutTab, - controlsTab, - ]; - }, [sessionId, setSnack, subRoute]); - - - useEffect(() => { - if (sessionId === undefined || activeTab > tabs.length - 1) { return; } - console.log(`route - '${tabs[activeTab].path}', subRoute - '${subRoute}'`); - - let path = tabs[activeTab].path ? `/${tabs[activeTab].path}` : ''; - if (subRoute) { - path += `/${subRoute}`; - } - path += `/${sessionId}`; - console.log('pushState: ', path); - // window.history.pushState({}, '', path); - }, [activeTab, sessionId, subRoute, tabs]); - - const fetchSession = useCallback((async (pathParts?: string[]) => { - try { - const response = await fetch(connectionBase + `/api/context`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw Error("Server is temporarily down."); - } - const new_session = (await response.json()).id; - console.log(`Session created: ${new_session}`); - - if (pathParts === undefined) { - setSessionId(new_session); - const newPath = `/${new_session}`; - window.history.replaceState({}, '', newPath); - } else { - const currentPath = pathParts.length < 2 ? '' : pathParts[0]; - let tabIndex = tabs.findIndex((tab) => tab.path === currentPath); - if (-1 === tabIndex) { - console.log(`Invalid path "${currentPath}" -- redirecting to default`); - window.history.replaceState({}, '', `/${new_session}`); - setActiveTab(0); - } else { - window.history.replaceState({}, '', `/${pathParts.join('/')}/${new_session}`); - // tabs[tabIndex].route = pathParts[2] || ""; - setActiveTab(tabIndex); - } - setSessionId(new_session); - } - } catch (error: any) { - console.error(error); - setSnack("Server is temporarily down", "error"); - } - }), [setSnack, tabs]); - - useEffect(() => { - const url = new URL(window.location.href); - const pathParts = url.pathname.split('/').filter(Boolean); // [path, sessionId] - - if (pathParts.length < 1) { - console.log("No session id or path -- creating new session"); - fetchSession(); - } else { - const currentPath = pathParts.length < 2 ? '' : pathParts[0]; - const path_session = pathParts.length < 2 ? pathParts[0] : pathParts[1]; - if (!isValidUUIDv4(path_session)) { - console.log(`Invalid session id ${path_session}-- creating new session`); - fetchSession(); - } else { - let tabIndex = tabs.findIndex((tab) => tab.path === currentPath); - if (-1 === tabIndex) { - console.log(`Invalid path "${currentPath}" -- redirecting to default`); - tabIndex = 0; - } - // tabs[tabIndex].route = pathParts[2] || "" - setSessionId(path_session); - setActiveTab(tabIndex); - } - } - }, [setSessionId, setSnack, tabs, fetchSession]); - - const handleMenuClose = () => { - setIsMenuClosing(true); - setMenuOpen(false); - }; - - const handleMenuTransitionEnd = () => { - setIsMenuClosing(false); - }; - - const handleMenuToggle = () => { - if (!isMenuClosing) { - setMenuOpen(!menuOpen); - } - }; - - const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { - if (newValue > tabs.length) { - return; - } - setActiveTab(newValue); - const tabPath = tabs[newValue].path; - let path = `/${sessionId}`; - if (tabPath) { - // if (openDocument) { - // path = `/${tabPath}/${openDocument}/${sessionId}`; - // } else { - path = `/${tabPath}/${sessionId}`; - // } - } - window.history.pushState({}, '', path); - handleMenuClose(); - }; - - useEffect(() => { - const handlePopState = () => { - const url = new URL(window.location.href); - const pathParts = url.pathname.split('/').filter(Boolean); - const currentPath = pathParts.length < 2 ? '' : pathParts[0]; - const session = pathParts.length < 2 ? pathParts[0] : pathParts[1]; - - let tabIndex = tabs.findIndex((tab) => tab.path === currentPath); - if (-1 === tabIndex) { - console.log(`Invalid path "${currentPath}" -- redirecting to default`); - tabIndex = 0; - } - setSessionId(session); - setActiveTab(tabIndex); - }; - - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, [setSessionId, tabs]); - - /* toolbar height is 64px + 8px margin-top */ - const Offset = styled('div')(() => ({ minHeight: '72px', height: '72px' })); - - return ( - - - theme.zIndex.drawer + 1, - maxWidth: "100vw" - }} - > - - - {!isDesktop && - - - - - - - - { setActiveTab(0); setMenuOpen(false); }} - > - - BACKSTORY - - - - } - - {menuOpen === false && isDesktop && - - {tabs.map((tab, index) => )} - - } - - - - - - - - - - - - - - {tabs.map((tab, index) => )} - - - - - { - tabs.map((tab: any, i: number) => - {tab.children} - ) - } - - - - - ); -}; - -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/BackstoryTab.tsx b/frontend/src/BackstoryTab.tsx index 8cbc199..22bcf51 100644 --- a/frontend/src/BackstoryTab.tsx +++ b/frontend/src/BackstoryTab.tsx @@ -5,7 +5,7 @@ import { ChatSubmitQueryInterface } from './ChatQuery'; import { SetSnackType } from './Snack'; interface BackstoryElementProps { - sessionId: string | undefined, + sessionId: string, setSnack: SetSnackType, submitQuery: ChatSubmitQueryInterface, sx?: SxProps, diff --git a/frontend/src/ChatQuery.tsx b/frontend/src/ChatQuery.tsx index 74b85cb..949defd 100644 --- a/frontend/src/ChatQuery.tsx +++ b/frontend/src/ChatQuery.tsx @@ -1,30 +1,32 @@ import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; -type QueryOptions = { +/* backstory/src/utils/message.py */ +type Tunables = { enable_rag?: boolean, enable_tools?: boolean, enable_context?: boolean, }; -type ChatSubmitQueryInterface = (prompt: string, tunables?: QueryOptions) => void; +/* backstory/src/server.py */ +type Query = { + prompt: string, + tunables?: Tunables, + agent_options?: {}, +}; + +type ChatSubmitQueryInterface = (query: Query) => void; interface ChatQueryInterface { - prompt: string, - tunables?: QueryOptions, + query: Query, submitQuery?: ChatSubmitQueryInterface } const ChatQuery = (props : ChatQueryInterface) => { - const { prompt, submitQuery } = props; - let tunables = props.tunables; - - if (typeof (tunables) === "string") { - tunables = JSON.parse(tunables); - } + const { query, submitQuery } = props; if (submitQuery === undefined) { - return ({prompt}); + return ({query.prompt}); } return ( ); } export type { ChatQueryInterface, - QueryOptions, + Query, ChatSubmitQueryInterface, }; diff --git a/frontend/src/ControlsPage.tsx b/frontend/src/ControlsPage.tsx index f05df20..3adf084 100644 --- a/frontend/src/ControlsPage.tsx +++ b/frontend/src/ControlsPage.tsx @@ -86,7 +86,8 @@ const SystemInfoComponent: React.FC<{ systemInfo: SystemInfo | undefined }> = ({ return
{systemElements}
; }; -const ControlsPage = ({ sessionId, setSnack }: BackstoryPageProps) => { +const ControlsPage = (props: BackstoryPageProps) => { + const { setSnack, sessionId } = props; const [editSystemPrompt, setEditSystemPrompt] = useState(""); const [systemInfo, setSystemInfo] = useState(undefined); const [tools, setTools] = useState([]); @@ -281,7 +282,7 @@ const ControlsPage = ({ sessionId, setSnack }: BackstoryPageProps) => { }, }); const data = await response.json(); - console.log("Server tunables: ", data); + // console.log("Server tunables: ", data); setServerTunables(data); setSystemPrompt(data["system_prompt"]); setMessageHistoryLength(data["message_history_length"]); diff --git a/frontend/src/Conversation.tsx b/frontend/src/Conversation.tsx index 91e4a92..c1b6a61 100644 --- a/frontend/src/Conversation.tsx +++ b/frontend/src/Conversation.tsx @@ -13,7 +13,7 @@ import { Message, MessageList, BackstoryMessage } from './Message'; import { ContextStatus } from './ContextStatus'; import { Scrollable } from './Scrollable'; import { DeleteConfirmation } from './DeleteConfirmation'; -import { QueryOptions } from './ChatQuery'; +import { Query } from './ChatQuery'; import './Conversation.css'; import { BackstoryTextField, BackstoryTextFieldRef } from './BackstoryTextField'; import { BackstoryElementProps } from './BackstoryTab'; @@ -24,7 +24,7 @@ const loadingMessage: BackstoryMessage = { "role": "status", "content": "Establi type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check'; interface ConversationHandle { - submitQuery: (prompt: string, options?: QueryOptions) => void; + submitQuery: (query: Query) => void; fetchHistory: () => void; } @@ -46,26 +46,27 @@ interface ConversationProps extends BackstoryElementProps { onResponse?: ((message: BackstoryMessage) => void) | undefined, // Event called when a query completes (provides messages) }; -const Conversation = forwardRef(({ - actionLabel, - className, - defaultPrompts, - defaultQuery, - hideDefaultPrompts, - hidePreamble, - messageFilter, - messages, - onResponse, - placeholder, - preamble, - resetAction, - resetLabel, - sessionId, - setSnack, - submitQuery, - sx, - type, -}: ConversationProps, ref) => { +const Conversation = forwardRef((props: ConversationProps, ref) => { + const { + sessionId, + actionLabel, + className, + defaultPrompts, + defaultQuery, + hideDefaultPrompts, + hidePreamble, + messageFilter, + messages, + onResponse, + placeholder, + preamble, + resetAction, + resetLabel, + setSnack, + submitQuery, + sx, + type, + } = props; const [contextUsedPercentage, setContextUsedPercentage] = useState(0); const [processing, setProcessing] = useState(false); const [countdown, setCountdown] = useState(0); @@ -238,12 +239,15 @@ const Conversation = forwardRef(({ }; const handleEnter = (value: string) => { - sendQuery(value); + const query: Query = { + prompt: value + } + sendQuery(query); }; useImperativeHandle(ref, () => ({ - submitQuery: (query: string, tunables?: QueryOptions) => { - sendQuery(query, tunables); + submitQuery: (query: Query) => { + sendQuery(query); }, fetchHistory: () => { return fetchHistory(); } })); @@ -295,17 +299,17 @@ const Conversation = forwardRef(({ stopRef.current = true; }; - const sendQuery = async (request: string, options?: QueryOptions) => { - request = request.trim(); + const sendQuery = async (query: Query) => { + query.prompt = query.prompt.trim(); // If the request was empty, a default request was provided, // and there is no prompt for the user, send the default request. - if (!request && defaultQuery && !prompt) { - request = defaultQuery.trim(); + if (!query.prompt && defaultQuery && !prompt) { + query.prompt = defaultQuery.trim(); } // Do not send an empty request. - if (!request) { + if (!query.prompt) { return; } @@ -318,7 +322,7 @@ const Conversation = forwardRef(({ { role: 'user', origin: type, - content: request, + content: query.prompt, disableCopy: true } ]); @@ -337,20 +341,7 @@ const Conversation = forwardRef(({ // Add a small delay to ensure React has time to update the UI await new Promise(resolve => setTimeout(resolve, 0)); - // Make the fetch request with proper headers - let query; - if (options) { - query = { - options: options, - prompt: request.trim() - } - } else { - query = { - prompt: request.trim() - } - } - - const response = await fetch(connectionBase + `/api/chat/${sessionId}/${type}`, { + const response = await fetch(connectionBase + `/api/${type}/${sessionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -393,7 +384,7 @@ const Conversation = forwardRef(({ prompt: ['done', 'partial'].includes(update.status) ? update.prompt : '', content: backstoryMessage.response || "", expanded: update.status === "done" ? true : false, - expandable: true, + expandable: update.status === "done" ? false : true, }] as MessageList); // Add a small delay to ensure React has time to update the UI await new Promise(resolve => setTimeout(resolve, 0)); @@ -568,7 +559,7 @@ const Conversation = forwardRef(({ sx={{ m: 1, gap: 1, flexGrow: 1 }} variant="contained" disabled={sessionId === undefined || processingMessage !== undefined} - onClick={() => { sendQuery((backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ""); }}> + onClick={() => { sendQuery({ prompt: (backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || "" }); }}> {actionLabel} diff --git a/frontend/src/Document.tsx b/frontend/src/Document.tsx index 207dcdd..22a76ee 100644 --- a/frontend/src/Document.tsx +++ b/frontend/src/Document.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Message } from './Message'; import { BackstoryElementProps } from './BackstoryTab'; -import { connectionBase } from './Global'; interface DocumentProps extends BackstoryElementProps { title: string; @@ -13,7 +12,12 @@ interface DocumentProps extends BackstoryElementProps { } const Document = (props: DocumentProps) => { - const { setSnack, submitQuery, filepath, content, title, expanded, disableCopy, onExpand, sessionId } = props; + const { sessionId, setSnack, submitQuery, filepath, content, title, expanded, disableCopy, onExpand } = props; + const backstoryProps = { + submitQuery, + setSnack, + sessionId + } const [document, setDocument] = useState(""); @@ -55,14 +59,11 @@ const Document = (props: DocumentProps) => { flexGrow: 0, }, message: { role: 'content', title: title, content: document || content || "" }, - connectionBase, - submitQuery, - setSnack, expanded, disableCopy, onExpand, - sessionId, - }} /> + }} + {...backstoryProps} /> ); }; diff --git a/frontend/src/HomePage.tsx b/frontend/src/HomePage.tsx index 9f354fe..a51a10e 100644 --- a/frontend/src/HomePage.tsx +++ b/frontend/src/HomePage.tsx @@ -32,10 +32,10 @@ What would you like to know about James? const backstoryQuestions = [ - - - - + + + + , diff --git a/frontend/src/App.css b/frontend/src/Main.css similarity index 100% rename from frontend/src/App.css rename to frontend/src/Main.css diff --git a/frontend/src/Main.tsx b/frontend/src/Main.tsx new file mode 100644 index 0000000..538ec25 --- /dev/null +++ b/frontend/src/Main.tsx @@ -0,0 +1,301 @@ +import React, { useEffect, useState, useRef, useMemo } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import Card from '@mui/material/Card'; +import { styled } from '@mui/material/styles'; +import Avatar from '@mui/material/Avatar'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import Tooltip from '@mui/material/Tooltip'; +import AppBar from '@mui/material/AppBar'; +import Drawer from '@mui/material/Drawer'; +import Toolbar from '@mui/material/Toolbar'; +import SettingsIcon from '@mui/icons-material/Settings'; +import IconButton from '@mui/material/IconButton'; +import Box from '@mui/material/Box'; +import CssBaseline from '@mui/material/CssBaseline'; +import MenuIcon from '@mui/icons-material/Menu'; + +import { ConversationHandle } from './Conversation'; +import { Query } from './ChatQuery'; +import { Scrollable } from './Scrollable'; +import { BackstoryPage, BackstoryTabProps } from './BackstoryTab'; + +import { HomePage } from './HomePage'; +import { ResumeBuilderPage } from './ResumeBuilderPage'; +import { VectorVisualizerPage } from './VectorVisualizer'; +import { AboutPage } from './AboutPage'; +import { ControlsPage } from './ControlsPage'; +import { SetSnackType } from './Snack'; + +import './Main.css'; +import './Conversation.css'; + +import '@fontsource/roboto/300.css'; +import '@fontsource/roboto/400.css'; +import '@fontsource/roboto/500.css'; +import '@fontsource/roboto/700.css'; + +interface MainProps { + sessionId: string, + setSnack: SetSnackType +} +const Main = (props: MainProps) => { + const { sessionId } = props; + const navigate = useNavigate(); + const location = useLocation(); + const [menuOpen, setMenuOpen] = useState(false); + const [isMenuClosing, setIsMenuClosing] = useState(false); + const [activeTab, setActiveTab] = useState(0); + const isDesktop = useMediaQuery('(min-width:650px)'); + const prevIsDesktopRef = useRef(isDesktop); + const chatRef = useRef(null); + const [subRoute, setSubRoute] = useState(""); + const backstoryProps = useMemo(() => { + const handleSubmitChatQuery = (query: Query) => { + console.log(`handleSubmitChatQuery:`, query, chatRef.current ? ' sending' : 'no handler'); + chatRef.current?.submitQuery(query); + setActiveTab(0); + }; + return {...props, submitQuery: handleSubmitChatQuery}; + }, [props, setActiveTab]); + + useEffect(() => { + if (prevIsDesktopRef.current === isDesktop) + return; + + if (menuOpen) { + setMenuOpen(false); + } + + prevIsDesktopRef.current = isDesktop; + }, [isDesktop, setMenuOpen, menuOpen]) + + const tabs: BackstoryTabProps[] = useMemo(() => { + const homeTab: BackstoryTabProps = { + label: "", + path: "", + tabProps: { + label: "Backstory", + sx: { flexGrow: 1, fontSize: '1rem' }, + icon: + , + iconPosition: "start" + }, + children: + }; + + const resumeBuilderTab: BackstoryTabProps = { + label: "Resume Builder", + path: "resume-builder", + children: + }; + + const contextVisualizerTab: BackstoryTabProps = { + label: "Context Visualizer", + path: "context-visualizer", + children: + }; + + const aboutTab = { + label: "About", + path: "about", + children: + }; + + const controlsTab: BackstoryTabProps = { + path: "controls", + tabProps: { + sx: { flexShrink: 1, flexGrow: 0, fontSize: '1rem' }, + icon: + }, + children: ( + + + + ) + }; + + return [ + homeTab, + resumeBuilderTab, + contextVisualizerTab, + aboutTab, + controlsTab, + ]; + }, [backstoryProps]); + + const handleMenuClose = () => { + setIsMenuClosing(true); + setMenuOpen(false); + }; + + const handleMenuTransitionEnd = () => { + setIsMenuClosing(false); + }; + + const handleMenuToggle = () => { + if (!isMenuClosing) { + setMenuOpen(!menuOpen); + } + }; + + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + if (newValue > tabs.length) { + console.log(`Invalid tab requested: ${newValue}`); + return; + } + setActiveTab(newValue); + + const tabPath = tabs[newValue].path; + + // Preserve the subroute if one is set + let path = tabPath ? `/${tabPath}` : ''; + if (subRoute) path += `/${subRoute}`; + path += `/${sessionId}`; + + console.log(`Pusing state ${path}`) + navigate(path); + handleMenuClose(); + }; + + useEffect(() => { + const pathParts = location.pathname.split('/').filter(Boolean); + const currentPath = pathParts.length < 2 ? '' : pathParts[0]; + const currentSubRoute = pathParts.length > 2 ? pathParts.slice(1, -1).join('/') : ''; + + let tabIndex = tabs.findIndex((tab) => tab.path === currentPath); + if (tabIndex === -1) { + console.log(`Invalid path "${currentPath}" -- redirecting to default`); + tabIndex = 0; + } + setActiveTab(tabIndex); + setSubRoute(currentSubRoute); + }, [location, tabs]); + + /* toolbar height is 64px + 8px margin-top */ + const Offset = styled('div')(() => ({ minHeight: '72px', height: '72px' })); + + return ( + + + theme.zIndex.drawer + 1, + maxWidth: "100vw" + }} + > + + + {!isDesktop && + + + + + + + + { setActiveTab(0); setMenuOpen(false); }} + > + + BACKSTORY + + + + } + + {menuOpen === false && isDesktop && + + {tabs.map((tab, index) => )} + + } + + + + + + + + + + + + + {tabs.map((tab, index) => )} + + + + { + tabs.map((tab: any, i: number) => + {tab.children} + ) + } + + + ); +}; + +export { + Main +} \ No newline at end of file diff --git a/frontend/src/Message.tsx b/frontend/src/Message.tsx index 517d1d7..6393b8d 100644 --- a/frontend/src/Message.tsx +++ b/frontend/src/Message.tsx @@ -15,7 +15,6 @@ import Button from '@mui/material/Button'; import CardContent from '@mui/material/CardContent'; import CardActions from '@mui/material/CardActions'; import Collapse from '@mui/material/Collapse'; -import Typography from '@mui/material/Typography'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { ExpandMore } from './ExpandMore'; import { SxProps, Theme } from '@mui/material'; @@ -86,7 +85,6 @@ interface MessageMetaData { eval_duration: number, prompt_eval_count: number, prompt_eval_duration: number, - sessionId?: string, connectionBase: string, setSnack: SetSnackType, } @@ -244,9 +242,14 @@ const MessageMeta = (props: MessageMetaProps) => { }; const Message = (props: MessageProps) => { - const { message, submitQuery, sx, className, onExpand, sessionId, setSnack } = props; + const { message, submitQuery, sx, className, onExpand, setSnack, sessionId } = props; const [metaExpanded, setMetaExpanded] = useState(false); const textFieldRef = useRef(null); + const backstoryProps = { + submitQuery, + sessionId, + setSnack + }; const handleMetaExpandClick = () => { setMetaExpanded(!metaExpanded); @@ -295,7 +298,7 @@ const Message = (props: MessageProps) => { overflow: "auto", /* Handles scrolling for the div */ }} > - + diff --git a/frontend/src/ResumeBuilderPage.tsx b/frontend/src/ResumeBuilderPage.tsx index 0d9d1a5..3591d61 100644 --- a/frontend/src/ResumeBuilderPage.tsx +++ b/frontend/src/ResumeBuilderPage.tsx @@ -6,7 +6,7 @@ import { } from '@mui/material'; import { SxProps } from '@mui/material'; -import { ChatQuery } from './ChatQuery'; +import { ChatQuery, Query } from './ChatQuery'; import { MessageList, BackstoryMessage } from './Message'; import { Conversation } from './Conversation'; import { BackstoryPageProps } from './BackstoryTab'; @@ -19,12 +19,13 @@ import './ResumeBuilderPage.css'; * A responsive component that displays job descriptions, generated resumes and fact checks * with different layouts for mobile and desktop views. */ -const ResumeBuilderPage: React.FC = ({ - sx, - sessionId, - setSnack, - submitQuery, -}) => { +const ResumeBuilderPage: React.FC = (props: BackstoryPageProps) => { + const { + sx, + sessionId, + setSnack, + submitQuery, + } = props // State for editing job description const [hasJobDescription, setHasJobDescription] = useState(false); const [hasResume, setHasResume] = useState(false); @@ -42,18 +43,18 @@ const ResumeBuilderPage: React.FC = ({ setActiveTab(newValue); }; - const handleJobQuery = (query: string) => { - console.log(`handleJobQuery: ${query} -- `, jobConversationRef.current ? ' sending' : 'no handler'); + const handleJobQuery = (query: Query) => { + console.log(`handleJobQuery: ${query.prompt} -- `, jobConversationRef.current ? ' sending' : 'no handler'); jobConversationRef.current?.submitQuery(query); }; - const handleResumeQuery = (query: string) => { - console.log(`handleResumeQuery: ${query} -- `, resumeConversationRef.current ? ' sending' : 'no handler'); + const handleResumeQuery = (query: Query) => { + console.log(`handleResumeQuery: ${query.prompt} -- `, resumeConversationRef.current ? ' sending' : 'no handler'); resumeConversationRef.current?.submitQuery(query); }; - const handleFactsQuery = (query: string) => { - console.log(`handleFactsQuery: ${query} -- `, factsConversationRef.current ? ' sending' : 'no handler'); + const handleFactsQuery = (query: Query) => { + console.log(`handleFactsQuery: ${query.prompt} -- `, factsConversationRef.current ? ' sending' : 'no handler'); factsConversationRef.current?.submitQuery(query); }; @@ -193,23 +194,26 @@ const ResumeBuilderPage: React.FC = ({ console.log('renderJobDescriptionView'); const jobDescriptionQuestions = [ - - + + , ]; const jobDescriptionPreamble: MessageList = [{ role: 'info', - content: `Once you paste a job description and press **Generate Resume**, the system will perform the following actions: + content: `Once you paste a job description and press **Generate Resume**, Backstory will perform the following actions: -1. **RAG**: Collects information from the RAG database relavent to the job description -2. **Isolated Analysis**: Three sub-stages - 1. **Job Analysis**: Extracts requirements from job description only - 2. **Candidate Analysis**: Catalogs qualifications from resume/context only - 3. **Mapping Analysis**: Identifies legitimate matches between requirements and qualifications -3. **Resume Generation**: Uses mapping output to create a tailored resume with evidence-based content -4. **Verification**: Performs fact-checking to catch any remaining fabrications - 1. **Re-generation**: If verification does not pass, a second attempt is made to correct any issues`, +1. **Job Analysis**: LLM extracts requirements from '\`Job Description\`' to generate a list of desired '\`Skills\`'. +2. **Candidate Analysis**: LLM determines candidate qualifications by performing skill assessments. + + For each '\`Skill\`' from **Job Analysis** phase: + + 1. **RAG**: Retrieval Augmented Generation collection is queried for context related content for each '\`Skill\`'. + 2. **Evidence Creation**: LLM is queried to generate supporting evidence of '\`Skill\`' from the '\`RAG\`' and '\`Candidate Resume\`'. +3. **Resume Generation**: LLM is provided the output from the **Candidate Analysis:Evidence Creation** phase and asked to generate a professional resume. + +See [About > Resume Generation Architecture](/about/resume-generation) for more details. +`, disableCopy: true }]; @@ -262,8 +266,8 @@ const ResumeBuilderPage: React.FC = ({ const renderResumeView = useCallback((sx: SxProps) => { const resumeQuestions = [ - - + + , ]; @@ -311,7 +315,7 @@ const ResumeBuilderPage: React.FC = ({ const renderFactCheckView = useCallback((sx: SxProps) => { const factsQuestions = [ - + , ]; diff --git a/frontend/src/SessionWrapper.tsx b/frontend/src/SessionWrapper.tsx new file mode 100644 index 0000000..272c213 --- /dev/null +++ b/frontend/src/SessionWrapper.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState, useRef } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { connectionBase } from './Global'; +import { SetSnackType } from './Snack'; + +const getSessionId = async () => { + const response = await fetch(connectionBase + `/api/context`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw Error("Server is temporarily down."); + } + + const newSession = (await response.json()).id; + console.log(`Session created: ${newSession}`); + + return newSession; +}; + +interface SessionWrapperProps { + setSnack: SetSnackType; + children: React.ReactNode; +} + +const SessionWrapper = ({ setSnack, children }: SessionWrapperProps) => { + const navigate = useNavigate(); + const location = useLocation(); + const [sessionId, setSessionId] = useState(undefined); + const fetchingRef = useRef(false); + + useEffect(() => { + const ensureSessionId = async () => { + const parts = location.pathname.split("/").filter(Boolean); + const pattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89ab][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/i; + const hasSession = parts.length !== 0 && pattern.test(parts[parts.length - 1]); + + if (!hasSession) { + let activeSession = sessionId; + if (!activeSession) { + activeSession = await getSessionId(); + setSessionId(activeSession); + } + + const newPath = [...parts, activeSession].join("/"); + navigate(`/${newPath}`, { replace: true }); + } + }; + + if (!fetchingRef.current) { + fetchingRef.current = true; + ensureSessionId().catch((e) => { + console.error(e); + setSnack("Backstory is temporarily unavailable.", "error"); + }); + } + }, [location.pathname, navigate, setSnack, sessionId]); + + return <>{children}; +}; + +export { SessionWrapper }; diff --git a/frontend/src/StyledMarkdown.tsx b/frontend/src/StyledMarkdown.tsx index e862044..d5a800d 100644 --- a/frontend/src/StyledMarkdown.tsx +++ b/frontend/src/StyledMarkdown.tsx @@ -21,7 +21,7 @@ interface StyledMarkdownProps extends BackstoryElementProps { }; const StyledMarkdown: React.FC = (props: StyledMarkdownProps) => { - const { className, content, submitQuery, sx, streaming } = props; + const { className, sessionId, content, submitQuery, sx, streaming } = props; const theme = useTheme(); const overrides: any = { @@ -75,6 +75,16 @@ const StyledMarkdown: React.FC = (props: StyledMarkdownProp a: { component: Link, props: { + onClick: (event: React.MouseEvent) => { + const href = event.currentTarget.getAttribute('href'); + console.log("StyledMarkdown onClick:", href, sessionId); + if (href) { + if (href.match(/^\//)) { + event.preventDefault(); + window.history.replaceState({}, '', `${href}/${sessionId}`); + } + } + }, sx: { wordBreak: "break-all", color: theme.palette.secondary.main, @@ -87,9 +97,16 @@ const StyledMarkdown: React.FC = (props: StyledMarkdownProp } }, ChatQuery: { - component: ChatQuery, - props: { - submitQuery, + component: (props: { query: string }) => { + const queryString = props.query.replace(/(\w+):/g, '"$1":'); + try { + const query = JSON.parse(queryString); + + return + } catch (e) { + console.log("StyledMarkdown error:", queryString, e); + return props.query; + } }, } }; diff --git a/frontend/src/VectorVisualizer.tsx b/frontend/src/VectorVisualizer.tsx index d857a54..65e1c2a 100644 --- a/frontend/src/VectorVisualizer.tsx +++ b/frontend/src/VectorVisualizer.tsx @@ -108,7 +108,7 @@ const symbolMap: Record = { }; const VectorVisualizer: React.FC = (props: VectorVisualizerProps) => { - const { setSnack, rag, inline, sessionId, sx, submitQuery } = props; + const { sessionId, setSnack, rag, inline, sx, submitQuery } = props; const [plotData, setPlotData] = useState(null); const [newQuery, setNewQuery] = useState(''); const [newQueryEmbedding, setNewQueryEmbedding] = useState(undefined); diff --git a/src/pyproject.toml b/src/pyproject.toml new file mode 100644 index 0000000..a0ee6fa --- /dev/null +++ b/src/pyproject.toml @@ -0,0 +1,22 @@ +[tool.black] +line-length = 88 +target-version = ['py312'] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.venv + | build + | dist +)/ +''' + +[tool.vulture] +min_confidence = 90 +ignore_decorators = [ + "@app.put", "@app.get", "@app.post", "@app.delete", "@app.patch", + "@self.app.put", "@self.app.get", "@self.app.post", "@self.app.delete", "@self.app.patch", + "@model_validator", + "@override", "@classmethod" +] +exclude = ["tests/", "__pycache__/"] \ No newline at end of file diff --git a/src/requirements.txt b/src/requirements.txt index 2a5f747..03e2549 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,173 +1,110 @@ accelerate==1.6.0 -aiofiles==24.1.0 aiohappyeyeballs==2.6.1 -aiohttp==3.11.16 +aiohttp==3.11.18 aiosignal==1.3.2 annotated-types==0.7.0 -ansi2html==1.9.2 -anthropic==0.49.0 anyio==4.9.0 -appdirs==1.4.4 -argon2-cffi==23.1.0 -argon2-cffi-bindings==21.2.0 -arrow==1.3.0 asgiref==3.8.1 -asttokens==3.0.0 -async-lru==2.0.5 attrs==25.3.0 -babel==2.17.0 +azure-ai-documentintelligence==1.0.2 +azure-core==1.34.0 +azure-identity==1.23.0 backoff==2.2.1 bcrypt==4.3.0 beautifulsoup4==4.13.4 -bigdl-core-xe-all==2.7.0b20250416 -bitsandbytes @ https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_multi-backend-refactor/bitsandbytes-0.44.1.dev0-py3-none-manylinux_2_24_x86_64.whl#sha256=66deda2b99cee0d4e52a183d9bac5c8e8618cd9b4d4933ccf23b908622d6b879 -bleach==6.2.0 +bigdl-core-xe-all==2.7.0b20250514 +bitsandbytes @ https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_multi-backend-refactor/bitsandbytes-0.45.3.dev272-py3-none-manylinux_2_24_x86_64.whl#sha256=7000dfb05889b2304ffdf98b54ebb6283ec4ff4020a9acbd5c357ae35345879c blinker==1.9.0 bs4==0.0.2 build==1.2.2.post1 cachetools==5.5.2 -certifi==2025.1.31 +certifi==2025.4.26 cffi==1.17.1 -chardet==5.2.0 -charset-normalizer==3.4.1 -chroma-hnswlib==0.7.6 -chromadb==0.6.3 +charset-normalizer==3.4.2 +chromadb==1.0.9 click==8.1.8 +cobble==0.1.4 coloredlogs==15.0.1 -comm==0.2.2 -contourpy==1.3.2 -cryptography==44.0.2 -cycler==0.12.1 -dash==3.0.3 -dataclasses-json==0.6.7 -datasets==3.5.0 -debugpy==1.8.14 -decorator==5.2.1 +cryptography==44.0.3 +curl_cffi==0.11.1 +datasets==3.6.0 defusedxml==0.7.1 Deprecated==1.2.18 diffusers==0.33.1 dill==0.3.8 distro==1.9.0 +dpcpp-cpp-rt==2025.0.4 durationpy==0.9 einops==0.8.1 -emoji==2.14.1 -eval_type_backport==0.2.2 -executing==2.2.0 -faiss-cpu==1.10.0 +et_xmlfile==2.0.0 fastapi==0.115.9 -fastjsonschema==2.21.1 feedparser==6.0.11 -ffmpy==0.5.0 filelock==3.13.1 -filetype==1.2.0 -Flask==3.0.3 +Flask==3.1.1 flask-cors==5.0.1 flask-sock==0.7.0 flatbuffers==25.2.10 -fonttools==4.57.0 -fqdn==1.5.1 frozendict==2.4.6 -frozenlist==1.5.0 +frozenlist==1.6.0 fsspec==2024.6.1 -gensim==4.3.3 geographiclib==2.0 geopy==2.4.1 -google-ai-generativelanguage==0.6.15 -google-api-core==2.24.2 -google-api-python-client==2.167.0 -google-auth==2.39.0 -google-auth-httplib2==0.2.0 -google-generativeai==0.8.4 +google-auth==2.40.1 googleapis-common-protos==1.70.0 -gradio==5.25.2 -gradio_client==1.8.0 -greenlet==3.2.0 -groovy==0.1.2 grpcio==1.71.0 -grpcio-status==1.71.0 -grpclib==0.4.7 -h11==0.14.0 -h2==4.2.0 -hpack==4.1.0 -html5lib==1.1 -httpcore==1.0.8 -httplib2==0.22.0 +h11==0.16.0 +httpcore==1.0.9 httptools==0.6.4 httpx==0.28.1 -httpx-sse==0.4.0 -huggingface-hub==0.30.2 +huggingface-hub==0.31.2 humanfriendly==10.0 -hyperframe==6.1.0 idna==3.10 impi-devel==2021.14.1 impi-rt==2021.14.1 importlib_metadata==8.6.1 importlib_resources==6.5.2 -ipykernel==6.29.5 -ipython==9.1.0 -ipython_pygments_lexers==1.1.1 -ipywidgets==8.1.6 -isoduration==20.11.0 +intel-cmplr-lib-rt==2025.0.4 +intel-cmplr-lib-ur==2025.0.4 +intel-cmplr-lic-rt==2025.0.4 +intel-opencl-rt==2025.0.4 +intel-openmp==2025.0.4 +intel-pti==0.10.1 +intel-sycl-rt==2025.0.4 +intel_extension_for_pytorch==2.6.10+xpu +ipex-llm @ file:///opt/wheels/ipex_llm-2.2.0.dev0-py3-none-any.whl#sha256=a209a95606d6b4c6fcb57f96e519537742be54fa6728b603986776e3915991f5 +isodate==0.7.2 itsdangerous==2.2.0 -jedi==0.19.2 Jinja2==3.1.4 -jiter==0.9.0 -joblib==1.4.2 -json5==0.12.0 +joblib==1.5.0 jsonpatch==1.33 jsonpointer==3.0.0 jsonschema==4.23.0 -jsonschema-specifications==2024.10.1 -jupyter-dash==0.4.2 -jupyter-events==0.12.0 -jupyter-lsp==2.2.5 -jupyter_client==8.6.3 -jupyter_core==5.7.2 -jupyter_server==2.15.0 -jupyter_server_terminals==0.5.3 -jupyterlab==4.4.0 -jupyterlab_pygments==0.3.0 -jupyterlab_server==2.27.3 -jupyterlab_widgets==3.0.14 -kiwisolver==1.4.8 +jsonschema-specifications==2025.4.1 kubernetes==32.0.1 -langchain==0.3.23 -langchain-chroma==0.2.3 -langchain-community==0.3.21 -langchain-core==0.3.52 -langchain-experimental==0.3.4 +langchain-core==0.3.59 langchain-ollama==0.3.2 -langchain-openai==0.3.13 -langchain-text-splitters==0.3.8 -langdetect==1.0.9 -langsmith==0.3.31 +langsmith==0.3.42 llvmlite==0.44.0 -lxml==5.3.2 +lxml==5.4.0 +magika==0.6.2 +mammoth==1.9.0 markdown-it-py==3.0.0 +markdownify==1.1.0 +markitdown==0.1.1 MarkupSafe==2.1.5 -marshmallow==3.26.1 -matplotlib==3.10.1 -matplotlib-inline==0.1.7 mdurl==0.1.2 -mistune==3.1.3 +mkl==2025.0.1 +mkl-dpcpp==2025.0.1 mmh3==5.1.0 -modal==0.74.4 -monotonic==1.6 mpmath==1.3.0 +msal==1.32.3 +msal-extensions==1.3.1 multidict==6.4.3 multiprocess==0.70.16 multitasking==0.0.11 -mypy-extensions==1.0.0 -narwhals==1.35.0 -nbclient==0.10.2 -nbconvert==7.16.6 -nbformat==5.10.4 -nest-asyncio==1.6.0 networkx==3.3 -nltk==3.9.1 -notebook_shim==0.2.4 numba==0.61.2 -numpy==1.26.4 +numpy==2.1.2 oauthlib==3.2.2 olefile==0.47 ollama==0.4.8 @@ -182,150 +119,107 @@ onemkl-sycl-rng==2025.0.1 onemkl-sycl-sparse==2025.0.1 onemkl-sycl-stats==2025.0.1 onemkl-sycl-vm==2025.0.1 -onnxruntime==1.21.0 -openai==1.75.0 -opentelemetry-api==1.32.1 -opentelemetry-exporter-otlp-proto-common==1.32.1 -opentelemetry-exporter-otlp-proto-grpc==1.32.1 -opentelemetry-instrumentation==0.53b1 -opentelemetry-instrumentation-asgi==0.53b1 -opentelemetry-instrumentation-fastapi==0.53b1 -opentelemetry-proto==1.32.1 -opentelemetry-sdk==1.32.1 -opentelemetry-semantic-conventions==0.53b1 -opentelemetry-util-http==0.53b1 -orjson==3.10.16 +onnxruntime==1.22.0 +openpyxl==3.1.5 +opentelemetry-api==1.33.0 +opentelemetry-exporter-otlp-proto-common==1.33.0 +opentelemetry-exporter-otlp-proto-grpc==1.33.0 +opentelemetry-instrumentation==0.54b0 +opentelemetry-instrumentation-asgi==0.54b0 +opentelemetry-instrumentation-fastapi==0.54b0 +opentelemetry-proto==1.33.0 +opentelemetry-sdk==1.33.0 +opentelemetry-semantic-conventions==0.54b0 +opentelemetry-util-http==0.54b0 +orjson==3.10.18 overrides==7.7.0 -packaging==24.1 +packaging==24.2 pandas==2.2.3 -pandocfilters==1.5.1 -parso==0.8.4 -peewee==3.17.9 +pdfminer.six==20250506 +peewee==3.18.1 peft==0.15.2 -pexpect==4.9.0 pillow==11.0.0 -platformdirs==4.3.7 -plotly==6.0.1 -posthog==3.25.0 +platformdirs==4.3.8 +posthog==4.0.1 +prometheus-fastapi-instrumentator==7.1.0 prometheus_client==0.21.1 -prompt_toolkit==3.0.51 propcache==0.3.1 -proto-plus==1.26.1 protobuf==5.29.4 psutil==7.0.0 -ptyprocess==0.7.0 -pure_eval==0.2.3 -pyarrow==19.0.1 +pyarrow==20.0.0 pyasn1==0.6.1 pyasn1_modules==0.4.2 pycparser==2.22 -pydantic==2.11.3 -pydantic-settings==2.8.1 -pydantic_core==2.33.1 +pydantic==2.11.4 +pydantic_core==2.33.2 pydub==0.25.1 Pygments==2.19.1 -PyHyphen==4.0.4 +PyJWT==2.10.1 pynndescent==0.5.13 -pyparsing==3.2.3 -pypdf==5.4.0 PyPika==0.48.9 pyproject_hooks==1.2.0 python-dateutil==2.9.0.post0 python-dotenv==1.1.0 -python-iso639==2025.2.18 -python-json-logger==3.3.0 -python-magic==0.4.27 -python-multipart==0.0.20 -python-oxmsg==0.0.2 -pytorch-triton-xpu==3.2.0 +python-pptx==1.0.2 +pytorch-triton-xpu==3.3.0 pytz==2025.2 PyYAML==6.0.2 -pyzmq==26.4.0 pyzt==0.0.2 -RapidFuzz==3.13.0 referencing==0.36.2 regex==2024.11.6 requests==2.32.3 requests-oauthlib==2.0.0 requests-toolbelt==1.0.0 -retrying==1.3.4 -rfc3339-validator==0.1.4 -rfc3986-validator==0.1.1 rich==14.0.0 rpds-py==0.24.0 rsa==4.9.1 ruamel.yaml==0.18.10 ruamel.yaml.clib==0.2.12 -ruff==0.11.5 -safehttpx==0.1.6 safetensors==0.5.3 scikit-learn==1.6.1 -scipy==1.13.1 -semantic-version==2.10.0 -Send2Trash==1.8.3 +scipy==1.15.3 sentence-transformers==3.4.0 -sentencepiece==0.2.0 sgmllib3k==1.0.0 shellingham==1.5.4 -sigtools==4.0.1 simple-websocket==1.1.0 six==1.17.0 -smart-open==7.1.0 sniffio==1.3.1 -soupsieve==2.6 -speedtest-cli==2.1.3 -SQLAlchemy==2.0.40 -stack-data==0.6.3 +soupsieve==2.7 +SpeechRecognition==3.14.3 starlette==0.45.3 -sympy==1.13.1 -synchronicity==0.9.11 +sympy==1.13.3 tbb==2022.1.0 tcmlib==1.2.0 tenacity==9.1.2 -terminado==0.18.1 threadpoolctl==3.6.0 tiktoken==0.9.0 -tinycss2==1.4.0 tokenizers==0.21.1 -toml==0.10.2 -tomlkit==0.13.2 -torch==2.6.0+xpu -torchaudio==2.6.0+xpu -torchvision==0.21.0+xpu -tornado==6.4.2 +torch==2.7.0+xpu +torchaudio==2.7.0+xpu +torchvision==0.22.0+xpu tqdm==4.67.1 -traitlets==5.14.3 transformers==4.51.3 -typer==0.15.2 -types-certifi==2021.10.8.3 -types-python-dateutil==2.9.0.20241206 -types-toml==0.10.8.20240310 -typing-inspect==0.9.0 +typer==0.15.4 typing-inspection==0.4.0 typing_extensions==4.12.2 tzdata==2025.2 umap-learn==0.5.7 umf==0.9.1 -unstructured==0.17.2 -unstructured-client==0.32.3 -uri-template==1.3.0 -uritemplate==4.1.1 urllib3==2.4.0 -uvicorn==0.34.1 +uvicorn==0.34.2 uvloop==0.21.0 watchdog==6.0.0 watchfiles==1.0.5 -wcwidth==0.2.13 -webcolors==24.11.1 -webencodings==0.5.1 websocket-client==1.8.0 websockets==15.0.1 -Werkzeug==3.0.6 -widgetsnbextension==4.0.14 +Werkzeug==3.1.3 wrapt==1.17.2 wsproto==1.2.0 +xlrd==2.0.1 +XlsxWriter==3.2.3 xxhash==3.5.0 -yarl==1.19.0 -yfinance==0.2.55 +yarl==1.20.0 +yfinance==0.2.61 +youtube-transcript-api==1.0.3 zipp==3.21.0 zstandard==0.23.0 diff --git a/src/server.py b/src/server.py index a97cea7..62b6ae6 100644 --- a/src/server.py +++ b/src/server.py @@ -20,7 +20,6 @@ import re import math import warnings from typing import Any -from collections import deque from datetime import datetime import inspect from uuid import uuid4 @@ -48,15 +47,12 @@ try_import("prometheus_client") try_import("prometheus_fastapi_instrumentator") import ollama -import requests from contextlib import asynccontextmanager -from fastapi import FastAPI, Request, BackgroundTasks # type: ignore +from fastapi import FastAPI, Request # type: ignore from fastapi.responses import JSONResponse, StreamingResponse, FileResponse, RedirectResponse # type: ignore from fastapi.middleware.cors import CORSMiddleware # type: ignore import uvicorn # type: ignore import numpy as np # type: ignore -import umap # type: ignore -from sklearn.preprocessing import MinMaxScaler # type: ignore # Prometheus from prometheus_client import Summary # type: ignore @@ -76,8 +72,6 @@ from utils import ( logger, ) -CONTEXT_VERSION = 2 - rags = [ { "name": "JPK", @@ -88,7 +82,7 @@ rags = [ ] -class QueryOptions(BaseModel): +class Query(BaseModel): prompt: str tunables: Tunables = Field(default_factory=Tunables) agent_options: Dict[str, Any] = Field(default={}) @@ -96,33 +90,6 @@ class QueryOptions(BaseModel): REQUEST_TIME = Summary("request_processing_seconds", "Time spent processing request") -system_message_old = f""" -Launched on {datetime.now().isoformat()}. - -When answering queries, follow these steps: - -1. First analyze the query to determine if real-time information might be helpful -2. Even when <|context|> is provided, consider whether the tools would provide more current or comprehensive information -3. Use the provided tools whenever they would enhance your response, regardless of whether context is also available -4. When presenting weather forecasts, include relevant emojis immediately before the corresponding text. For example, for a sunny day, say \"☀️ Sunny\" or if the forecast says there will be \"rain showers, say \"🌧️ Rain showers\". Use this mapping for weather emojis: Sunny: ☀️, Cloudy: ☁️, Rainy: 🌧️, Snowy: ❄️ -4. When both <|context|> and tool outputs are relevant, synthesize information from both sources to provide the most complete answer -5. Always prioritize the most up-to-date and relevant information, whether it comes from <|context|> or tools -6. If <|context|> and tool outputs contain conflicting information, prefer the tool outputs as they likely represent more current data - -Always use tools and <|context|> when possible. Be concise, and never make up information. If you do not know the answer, say so. -""".strip() - - -system_fact_check_QA = f""" -Launched on {datetime.now().isoformat()}. - -You are a professional resume fact checker. - -You are provided with a <|resume|> which was generated by you, the <|context|> you used to generate that <|resume|>, and a <|fact_check|> generated by you when you analyzed <|context|> against the <|resume|> to identify dicrepancies. - -Your task is to answer questions about the <|fact_check|> you generated based on the <|resume|> and <|context>. -""" - def get_installed_ram(): try: @@ -191,7 +158,6 @@ def get_cpu_info(): except Exception as e: return f"Error retrieving CPU info: {e}" - def system_info(model): return { "System RAM": get_installed_ram(), @@ -202,13 +168,11 @@ def system_info(model): "Context length": defines.max_context, } - # %% # Defaults OLLAMA_API_URL = defines.ollama_api_url MODEL_NAME = defines.model LOG_LEVEL = "info" -USE_TLS = False WEB_HOST = "0.0.0.0" WEB_PORT = 8911 DEFAULT_HISTORY_LENGTH = 5 @@ -216,15 +180,7 @@ DEFAULT_HISTORY_LENGTH = 5 # %% # Globals - -def create_system_message(prompt): - return [{"role": "system", "content": prompt}] - - -tool_log = [] -command_log = [] model = None -client = None web_server = None @@ -668,7 +624,7 @@ class WebServer: try: data = await request.json() - query: QueryOptions = QueryOptions(**data) + query: Query = Query(**data) except Exception as e: error = {"error": f"Attempt to parse request: {e}"} logger.info(error) @@ -692,7 +648,7 @@ class WebServer: context=context, agent=agent, prompt=query.prompt, - options=query.options, + tunables=query.tunables, ): if message.status != "done" and message.status != "partial": if message.status == "streaming": @@ -1019,16 +975,18 @@ class WebServer: @REQUEST_TIME.time() async def generate_response( - self, context: Context, agent: Agent, prompt: str, options: Tunables | None + self, context: Context, agent: Agent, prompt: str, tunables: Tunables | None ) -> AsyncGenerator[Message, None]: if not self.file_watcher: raise Exception("File watcher not initialized") agent_type = agent.get_agent_type() logger.info(f"generate_response: type - {agent_type}") - message = Message(prompt=prompt, options=agent.tunables) - if options: - message.tunables = options + # Merge tunables to take agent defaults and override with user supplied settings + agent_tunables = agent.tunables.model_dump() if agent.tunables else {} + user_tunables = tunables.model_dump() if tunables else {} + merged_tunables = {**agent_tunables, **user_tunables} + message = Message(prompt=prompt, tunables=Tunables(**merged_tunables)) async for message in agent.prepare_message(message): # logger.info(f"{agent_type}.prepare_message: {value.status} - {value.response}") diff --git a/src/tests/__pycache__/__init__.cpython-311.pyc b/src/tests/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c7ae2b23b886c978a1f5c482cbdb274f50a16e48 GIT binary patch literal 145 zcmZ3^%ge<81m7ElGC=fW5CH>>P{wCAAY(d13PUi1CZpdd}dx|NqoFsLFF$Fo80`A(wtPgB37U#kg>)5K;i>4 PBO~Jn1{hJq3={(ZW~3jk literal 0 HcmV?d00001 diff --git a/src/tests/__pycache__/test-agent.cpython-311.pyc b/src/tests/__pycache__/test-agent.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..104cc244a04f645fedd31fe082a7703869816050 GIT binary patch literal 1745 zcmc&!&1>UE6d&38iY(b1J5K6NDGnx1QqnkO!!Cr<%hElz&|Vr*5L#>ep_ME%8mCR@ zL5Dr$4=B`77JMivB|hx2|3pCwf|xlOdfJ=I_Oz$Y*pX|8QdoM}r4`n(r+*Ftr8EwA52L|xD{T%OabsCuz0+~8hhYi%6!=Q1VuH;E+iUUT}Cf!dJbk`&dAQuxxrc@FRoU` zQis|ljroes8R9IPkW;(AGytGPknpb>HQdx^oy!i}H6i_~%?9JqtZ!J=JC&iotR zx(AEK9P!TTbw7!5akVnb+3Cf%&My5G*js(Ys=db%(eav1h+eTfKHp1|G%}k+dhB&D z{0H0AqTOpBzYPa!p2folI7J|`b74g`VgH$gji1!seW5Jg{A8l zR?qDc-ZHUC3Ny>8>9na14OSN-Fn~`AO^6K-8M7K1(|n0ATe58Lp9&CVLFU_?hKE=` zla)E_SMZt|z?*8#>*Cr|v;NFyZ&dS9y@sKW{VD%w>gJ$(_D24MM%HuSr)Y;BV?Q4M zcgiPej^jpPGh7~Hpas)!!t-2e0^WwHfK!7E`^I7+XgqiO8*!-?iieSS7>kENdR(gY z3)D1{Go!W5AHEoA`H95ePyGof`-`y%tOxSN{zL_t{KZcH`%u}Bl>J!Q52TTly^%`S zQmKC$N~K7u#!@xlN6PlaPONMX?1A;O{c)zLUpHqOc@;!*p0z%2zKXN z5Bf0v;NwtzKt}|QVsI3Kqw)HOfe?ej`2Pl4x|U1*Y9w#P@>U@H_oeh+I?y(K*1rLy CR;7&q literal 0 HcmV?d00001 diff --git a/src/tests/__pycache__/test-chunker.cpython-311.pyc b/src/tests/__pycache__/test-chunker.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3659725041659693b3f63cd133a3d8587d616478 GIT binary patch literal 996 zcmb7C&1(}u6rY(S`_-hI(u0&zDWru)Y!v+ z4@HDtgGi#b;MG6DV^$Vj=Az)Kw?J<_IlIY9)`Q@CGw;3MypMTrm_ebS08R_vO1lFH zz^@1-Bu|Ws1MWEi7+}~2`dC99#u({{z-A^%>17jZS`#!Ck(>4;b}B+hPN#J$LY(#_ zHj(HJ4DA#$1e~XCMKp^o@3c2wM>?wdz2xI zvaDTmcbQPLZDL|3)^=!okZoEGlZm+Dn6(aJl3_e+w25J`VrSB+Q6IPJME{0~6U6ei zW7P?zaXb^^W6E-|W;xqwm@doVZpWq{z(w>Bmus43yV_2z-lDGM?EN+O`lN8h-n*1; zcP!j(lRM4=PmKNu{lUd310fs<0Oq}!h(n-wF^53*Vh%y(KzgOVRFBac@kD*C_Ru?V zkbj$h|LE-TC*z~>`H8>28LV%9QT*ktV0p_gZU@C}A5;TS9fE2It`F1er?POU`O0FT zEP7%n7r)C(U*)CY)tkP&5y%^!5EkY~0KwH*c=tmjdaH-h3++Jjv@uW)vq#GDt=`;F YD)~|=;1aC_Xk{3$Q7%b#uJ9Ru1K;Z73;+NC literal 0 HcmV?d00001 diff --git a/src/tests/__pycache__/test-context-routing.cpython-311.pyc b/src/tests/__pycache__/test-context-routing.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6337710c18d5a392a4e6a420e41d5a6eefc10a61 GIT binary patch literal 10272 zcmd5iU2GdidizImNveGg*Ja5AB#Nm*60E zWg8|M6O9v1i6&g{&ITr$6U`(+5u$&*#ae(E{FEe!&*5i=#3qV3O$eUbgusfMKc%4# zewDgbT<3*4->0zN&*4|83BjnO;J?qU8LdrhD~7FB_7ui;%qujAl+Y+{9$|#0+svoX z^z&--Li)6jNXwSg_; zT#7PLLxun7TRvRoZ}?6!=888rm1Fy@CN4~@f>J;Mu@B% zzf%kfTi(VPl)^wSOg5T`!ak$pJFTh2HesvKe!s&>1wc*AF#g51{Sh^dF^1<+}a zA22*a;yY8KtQek?;-r`pWW)99lqlVZQAS`$6f%;SR)$1*GM|%0gL`H8T-+)_3+Cd* zfs=-7YAPcfh(np}Pi3>ID_PNC&rK?sd@hxZkp?SG<&yG^Tsm#76QvfBK8Zr%4qPRX zNRR@NpafE+N2obsgcPXTT!NV+u9FGZFcG7TW@|+k^8NXoB3@SvdP>Tst7HvBF_p2O zEI{T%@*XiwP7_JUn?bR)E!y%{JpmVbn*50P8_J0^l2AOgHcr$R(VXkT)5Z}9*b5ae zY_>MTD|x=MogO~sk^(T4;m+nqM@7kCGP#kw;gY8&p};1SnOsImCgCg?SXc1zYEs_t{mp@mK!${OYECi~Rr~YV7km`@G6Nzsfe; z^}O#{VcVA3HZ^?cK@b2$jXkWhhgB;+f$@l#8*MC?nh=vo!q!dDHLmcrXsSTjiWC1^_I0*n2ZT*!g+V#Rhf0=;fM*#`( zMLf2HkgF2mO{iW=#z4X#4+Q!)k#Grq!6mu{x9AbsQBw4Zz8a$)p=@~uxVZ~t11q|L z?|5MZPVfQIa7M5~CqsZY@u#U=3Xuq%nw)f80Yuh_5T5kn6*(`!G*PU|4ydjMCrKj- z1T&IwtEKJG#%Pp9Woj}xmdXiPQIg?vD}2UvGMbO)&7;R;w6)GMtAvCknT*D)(ou=! zR@g1e>=un}*V%T}iul0O_SL$e8{U}$8MxZSIHYE%zbnOdHJuk)AQiXO7cMU_@OyFv@0^XXJp?(2HWAowP~08{=K{LK>o$+i4y z@V!B`?ZpT5Z#|Ge)VLm<>ruI$)nMl$ec!V*tOk#3!Q*=HxXK;JGy^-rMB5Aq60sn% z2-jo{2u~IT?_IM$sS`dFcKq;O5dF);0Dz9Y4ez(DcsrN9onJ=}YTiS-_fVOlyeG&n z50?p)etmq%%-G>KG~qj3ZECNLog3fst1loR26pP!zEOw4HM}oL!ZgVr#GzCQHiWl||iwcr0{M000*#|FR6+_IW zZ}1~|iN{;wCsPuzenpgec$4CugRvdA-U}b_wR#T__aXTsE*9|v7Yhcq!c#6M1w00E z931LhPJ8_fT;(Dnu65xq>*PK&)CTZFPg5UJ0;3=)RteK<)Dl<`;~beJrl~)RDGgQ+ z0?JBbt>k1F(xz#uXv3+k9b{`7z5L4!tk<<}CARY)M_9z6h^;HDWTk@J*?Wdo5HqL* z1|Tdh4IEIBdb*q3_)GkT}p^=6!Q4-=h)VzZi}g!H#F9><7CnD#hNA7)%nfB*ln&izlYE)X-Go1SarHR3vbnYc#A8sde0FM|-25StTo@{}Z2 zrDBcM?d2;b7JqT*Y!6>(IG<9+Os*%RqRH5=rm|Ba6xqGJa$^$ZeqyWOGcupaL7R-Q zmmd{#qLjkCH78!V3y;Ip!5^4uhzjZewAGt3nk zo=T@hSst0nW^bUi$)aG@?LL*tWT6N{nnR!o`4K5UVIK?z@$ThCNy-BsFSHMsi^XH? zt*RlFmBgCYC>(}=63!%vC4p?vqoQIk=oAcJM$Y6wRmr7AsU1iV3?W6zOd1So4{{Kf zB*g0^#8gb_ju?&MyMzw(Wwai{g_jlcn&RaEs`lFsS9%eDfwQ!*XK7Y_D4eRNQaGE})T5`5gIpT&InWosGgYKjOGUc;==8sW zrgRyi#wBzvp>m08<%}z?I=Wz>(jYDmFs&P*{(;Pof|AgcK%)WUw1F=(IN-+ z$icb6YPX?9zuGng@psC@LG9Ri{n&Z4tZ~CSH>`5QkDDTD(~d9pJb2@)PHoRAea|T< zK6-m4o?MP6wRlR8r{KeCp!J^r*M4>DD_USs4-Be-!BVK>gLn1N?v+s2a;R%j)DOt{NYXvI4d4#w(08ai^c~m#Mw7lv)ORsCZSD75bJUz`j-DgI>E!~C0SkHzcBFo#9*G)!#i|iWWyrE?a99b_zjEo+o+dNoUFH+=&%tr$oZB>S z-9yL};tShAu9$}d(&<_TglbjjW<(IJ24^qFJO-16qcq&$E&)fUgyy#qH8L2bg8)#& zk*QqT2!Jy)X&*v5h1&E3Fx+onOG%?L79E)UWn?ph=M4o@7I+0Qod-FcmkI~!v3^qm zfgl6y=4zb3+6!N$85kAaf^D6}v)Z_b!L`YT+(D+%?x&W*F~9vP}F)$Pt0C z%C_Mh$HA-E1+&K*qzEJuG6y8Ymn38iVmBmq6kx4HLI#@T` z30!95cDkMWOq0$#U(cp-*e-SX8Soy5-40xZ7)Kbwp`O*UfiW4uRjhI?Yb=!7t?lvZ zGXc2js+P5LPtH=^9U`7y34&Yj{HYTX*pI3EUZ?d4oYDtR3>R~zpH1Mao-okP8yp77 z_f32G^G=gwSb>KFUXfC1FhmR@^DUflVeK;dmqqESD4EJWxY2rHorYTw6|fVa zxCa$ISXs^~F`vmfP#g2EAX8JqIyf_)&*Tg*o(8>C7+!D+O{A2has|2gtKXAJ37w<# z695iRWZP&qe#^RYVf|=ai=ESB z=d|tT_3i6A`9q~mp;Bbu?}i@+|M~TQd0h>hf>?IZockLBz_$pX=Lk2xc?L74C{C|_ z^={3>o;H5BfbPLqFaws!fBIgm@xq|jXY_vHq*#Y=)U!3tEl}@}G^>QQ%y^m-=#POYAhbJD~sXUD5Uq9J~dQJ_M`L;)Nq1HW%oJ`lvGFv#cDP{a)K_oC-#0Ad~+N_)^(?1-sh9c>q` zKf2(UButeVsdv&b05UpY>_jmY#hRvJ;e7-eq*2RB04Cla`&-^WqWib3_;)P(cNF() z{@uEN_w32lme619{$Tg)V5v2t22Vg-c*9H|jGO7~S;W3EhdA-ztQt50QDgdbre9_H zSN&Ux+)}6N@6-H!y1!3l`b=gF&(jmBOfH!$c%JLRf0^I!fNjBlK#b+Vq+llVk`mix zct+sADu6?nN+MD>Oe+Iv!$xzZva8D4@H-opZ3ct>hXR|^`1}kHdj1DiDk1)6xPddE z0@gE;;cYafayK&hE+wB-#w0N%U>}&_h5K?A|KEVAwIVDS-j}UMFw;H8!$8`M7T-l|EJ+AEhG*((25g<2LBqd#NO9C6 zi~v%3zh`^R2mLf_^(6+R4bN< zooc;UBBH7@KK4fDLb^Ay@Pg)z>fY$A8_Wq3{!?H*Snz&>zJ6P);trFbQm+T!pl{#S zDkPHdG|rALD5^WExud!}I!iw$nH92inQWcEp^@8la(kKbknTmW2OwFB;iLxO?U34G zH7d7&&2XpVR>!nVF}*o9+xVDl zS|Qt)$#%8lMUCvy$)3`Y6HA95_O0~4wcP)f)_+CszoH#U>qpY+wP}cDg1AW@ryw~; zT}8=t@*4GxnS5&|V8@UzgW-bU!ZkB}aMDcGcNBZInMZ3p0#k!AggUZlW1(B2xekql zrMH!iom`rFIILcpQIFl!j@{Ic-7FI>@)!n1I4j66fk9+_!Z|c%7&@0J^tq8>xVgeI t9aiaZiD{XQqrVc4uCM%PxY6KzmHq{|(E}6!QQ8 literal 0 HcmV?d00001 diff --git a/src/tests/__pycache__/test-context.cpython-311.pyc b/src/tests/__pycache__/test-context.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e61d3bbc9d1619299579451154c495541332812 GIT binary patch literal 1129 zcmb7C&2G~`5MDcu*Kz(JEmWZu3KD851kwknxP(I0RMf|8II){H743}~c|1SgH#;8hmuyxCTyoE@?KETn zeu`is$usBVr3Ao7fB=#lU;rdz(Bhd>L5XGVjBBzX%K*!*#Cc6Y$-a8P`yTS=RHRd^ zVkihA%}Khd0bR||+?0_ztLcW$`zWpSgo9J9%*5}f4*1!J=jR()j)kqi&3ou5QV z@23y=Jcs8qz2cJ?SB$HirQxgn%%yGxT=+aSVgLI^*?#U4c3SjkU7MhIq_?h5B0PN& zJwEHgCX51-o`J>uv7kI<$(34dv&N>LZ`P`-W_4@J+}M0#Zmhgm*n=(Rx;O}|CO#GmUrewBv6>84eUIWdltF|!5eFQo z?DzqFED(<4viz<^>pLd039eJ0>@kfy1X~nyXr^I1*c>A;VM;6TJ<-7R5nh~OvL7&r zy{=7sk7>IW@ocXdupB2*OwMD~cO1*LSh7QHCmjf_897lS$`>bPyDAd6Zo z)gZoWitk`akq*9s6HWnt?o|9Xt!!KM*MZnVB_Q<*#Q_b*_WoJN2kpH-+A{CjaqvSj z&xztT2GiV*5|SjvAQg@^zI`v0-|K^PX$Wq`ARmr31~Z`+ftj8*xVtt0>qD>}fpzX2 zfowR|k)lZQ2>g%904Woug&wI>VdK5qn~92*fx0wQmm+m3OdJ&}y(ylGwRAi^`(ZQA z7DiCHA^!s0{1#>iu7}!wd6WU^!u~?<-9Rr7^>U<_Lm0!{4_N#Ti@oju7Kd;?g7cvg o>$Cd{kv`j7>pl9k_L&an%F$f;+wwqP9qOx*z8XTING`kYJ>BoE+$M|=o~ zONitOa>Z4UtDfeouIlNorT|lz)}zk^;0^E0TpjC_>5s;uvK&35XRh|IX1GB+he~d& zpsGSr>WB3@Je|Y;?$B7|I%v*(JM@_1EvQ~7{$(2wRS7wkarxxYmQtf z{_t=<;3(=$&0|!zeRje(P$>UHBnX*G2+1gry*}(Nd!{Kw$>2XQfvhKM@_glX5<0Rr u@AtdgeajwL_Rz8uV+4AlC-qNS{Gi3|b)Oaov^1op0hEVO?k}7d@Dab~4%GHkG8A{~)-K(=1X*`Da%D@L&IWHPbV#66 zmv$kwM(IL<&n;~1K-q{w|EqRCTy?5`upYFZ;n$KGxW%T9o$wvh6 zM>(nJE93U1jC=wD1bNUyErft*MEA6orU6CgGVLO*ftF77u93R2nOf_b7?9m5}Q+D2F&gAZ*Z$Fd-tU72fW_4owp(P*{B{er_O1}!^Qq| zb&pywy30P7pn*r5?4c}FuO0r7+gwKo%`+O(=5XJZ1+*RI)S71?w{v=MR{cEM)B)Ae8SJwXNL1OL4yA!LDSd|E0gBh7o=Uw4_AzCc9CmZ<- DrjNYP literal 0 HcmV?d00001 diff --git a/src/tests/test-chunker.py b/src/tests/test-chunker.py new file mode 100644 index 0000000..6bad6f3 --- /dev/null +++ b/src/tests/test-chunker.py @@ -0,0 +1,14 @@ +# From /opt/backstory run: +# python -m src.tests.test-chunker +import json +import sys +import os + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../utils"))) +from markdown_chunker import MarkdownChunker # type: ignore + +chunker = MarkdownChunker() +chunks = chunker.process_file("./src/tests/test.md") # docs/resume/resume.md") +for chunk in chunks: + print("*" * 50) + print(json.dumps(chunk, indent=2)) diff --git a/src/tests/test.md b/src/tests/test.md new file mode 100644 index 0000000..a18397a --- /dev/null +++ b/src/tests/test.md @@ -0,0 +1,19 @@ +# Test header + +Text under header + +## Test nested header + +Text under nested header + +* Top level bullet list + * Second level bullet list + * Another item on second level bullet list + * Third item bullet list + +Paragraph under 'Test nested header' + +1. Top level number list + 1. Second level number list + 2. Another item on second level number list + 1. Third item number list \ No newline at end of file diff --git a/src/utils/agents/base.py b/src/utils/agents/base.py index 1b9173f..9a9dc50 100644 --- a/src/utils/agents/base.py +++ b/src/utils/agents/base.py @@ -1,5 +1,5 @@ from __future__ import annotations -from pydantic import BaseModel, PrivateAttr, Field # type: ignore +from pydantic import BaseModel, Field # type: ignore from typing import ( Literal, get_args, diff --git a/src/utils/agents/job_description.py b/src/utils/agents/job_description.py index b2e09ba..4137966 100644 --- a/src/utils/agents/job_description.py +++ b/src/utils/agents/job_description.py @@ -4,24 +4,22 @@ from typing import ( Dict, Literal, ClassVar, - Optional, Any, AsyncGenerator, List, + Optional +# override ) # NOTE: You must import Optional for late binding to work -from datetime import datetime import inspect import re import json import traceback import asyncio import time -from collections import defaultdict import asyncio import numpy as np # type: ignore from .base import Agent, agent_registry, LLMMessage -from ..conversation import Conversation from ..message import Message from ..setup_logging import setup_logging @@ -31,9 +29,6 @@ logger = setup_logging() system_generate_resume = """ """.strip() -system_job_description = f""" -""".strip() - system_user_qualifications = """ Answer questions about the job description. """ @@ -55,6 +50,7 @@ class JobDescription(Agent): raise ValueError("Job description cannot be empty") return self + #@override async def prepare_message(self, message: Message) -> AsyncGenerator[Message, None]: logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") if not self.context: @@ -69,6 +65,7 @@ class JobDescription(Agent): yield message return + #@override async def process_message( self, llm: Any, model: str, message: Message ) -> AsyncGenerator[Message, None]: @@ -211,43 +208,6 @@ class JobDescription(Agent): # Additional validation can be added here - def validate_candidate_qualifications(self, candidate_qualifications: Dict) -> None: - """Validate the structure of candidate qualifications.""" - required_keys = ["candidate_qualifications"] - - if not all(key in candidate_qualifications for key in required_keys): - missing = [ - key for key in required_keys if key not in candidate_qualifications - ] - raise ValueError( - f"Missing required keys in candidate qualifications: {missing}" - ) - - # Additional validation can be added here - - def validate_skills_mapping(self, skills_mapping: Dict) -> None: - """Validate the structure of skills mapping.""" - required_keys = ["skills_mapping", "resume_recommendations"] - - if not all(key in skills_mapping for key in required_keys): - missing = [key for key in required_keys if key not in skills_mapping] - raise ValueError(f"Missing required keys in skills mapping: {missing}") - - # Additional validation can be added here - - def extract_header_from_resume(self, resume: str) -> str: - """Extract header information from the original resume.""" - # Simple implementation - in practice, you might want a more sophisticated approach - lines = resume.strip().split("\n") - # Take the first few non-empty lines as the header - header_lines = [] - for line in lines[:10]: # Arbitrarily choose first 10 lines to search - if line.strip(): - header_lines.append(line) - if len(header_lines) >= 4: # Assume header is no more than 4 lines - break - return "\n".join(header_lines) - def generate_resume_from_skill_assessments( self, candidate_name, @@ -854,113 +814,6 @@ IMPORTANT: Be factual and precise. If you cannot find strong evidence for this s metadata["error"] = message.response raise - def format_rag_context(self, rag_results: List[Dict[str, Any]]) -> str: - """ - Format RAG results from process_job_requirements into a structured string. - - Args: - rag_results: List of dictionaries from process_job_requirements. - - Returns: - A formatted string for inclusion in the prompt. - """ - if not rag_results: - return "No additional context available." - - # Group results by category and subcategory - grouped_context = defaultdict(list) - for result in rag_results: - key = f"{result['category']}/{result['subcategory']}".strip("/") - grouped_context[key].append( - { - "query": result["context"], - "content": ( - result["content"][:500] + "..." - if len(result["content"]) > 500 - else result["content"] - ), - } - ) - - # Format as a structured string - context_lines = ["Additional Context from Document Retrieval:"] - for category, items in grouped_context.items(): - context_lines.append(f"\nCategory: {category}") - for item in items: - context_lines.append(f"- Query: {item['query']}") - context_lines.append(f" Relevant Document: {item['content']}") - - return "\n".join(context_lines) - - # Stage 1B: Candidate Analysis Implementation - def create_candidate_analysis_prompt( - self, resume: str, rag_results: List[Dict[str, Any]] - ) -> tuple[str, str]: - """Create the prompt for candidate qualifications analysis.""" - system_prompt = """\ -You are an objective resume analyzer. Create a concise inventory of the candidate's key skills, experiences, and qualifications based on their resume. - -CORE PRINCIPLES: -- Analyze ONLY the candidate's resume and provided context. -- Focus on the most significant and relevant qualifications explicitly mentioned. -- Limit your analysis to the most important items in each category. -- Prioritize brevity and completeness over exhaustiveness. -- Complete the entire analysis in one response without getting stuck on any section. - -OUTPUT FORMAT: -{ - "candidate_qualifications": { - "technical_skills": [ - // Include MAX 10 most important technical skills - { - "skill": "skill name", - "evidence_location": "brief reference", - "expertise_level": "stated level or 'unspecified'" - } - ], - "work_experience": [ - // Include MAX 5 most recent or relevant positions - { - "role": "job title", - "company": "company name", - "duration": "time period", - "responsibilities": ["resp1", "resp2"], // MAX 3 key responsibilities - "technologies_used": ["tech1", "tech2"], // MAX 5 technologies - "achievements": ["achievement1"] // MAX 2 achievements - } - ], - "education": [ - // Include ALL education entries (typically 1-3) - { - "degree": "degree name", - "institution": "institution name", - "completed": true/false - } - ], - "projects": [ - // Include MAX 3 most significant projects - { - "name": "project name", - "description": "one sentence description", - "technologies_used": ["tech1", "tech2"] // MAX 3 technologies - } - ], - "soft_skills": [ - // Include MAX 5 most prominent soft skills - { - "skill": "skill name", - "context": "brief mention" - } - ] - } -} - -IMPORTANT: If at any point you find yourself repeating items or getting stuck, STOP that section and move to the next. It's better to provide a partial analysis than to get stuck in a loop. -""" - context = self.format_rag_context(rag_results) - prompt = f"Resume:\n{resume}\n\nAdditional Context:\n{context}" - return system_prompt, prompt - async def call_llm(self, message: Message, system_prompt, prompt, temperature=0.7): logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") @@ -1018,581 +871,6 @@ IMPORTANT: If at any point you find yourself repeating items or getting stuck, S message.status = "done" yield message - async def analyze_candidate_qualifications( - self, - message: Message, - resume: str, - rag_context: List[Dict[str, Any]], - metadata: Dict[str, Any], - ) -> AsyncGenerator[Message, None]: - """Analyze candidate qualifications from resume and context.""" - try: - system_prompt, prompt = self.create_candidate_analysis_prompt( - resume, rag_context - ) - metadata["system_prompt"] = system_prompt - metadata["prompt"] = prompt - async for message in self.call_llm(message, system_prompt, prompt): - if message.status != "done": - yield message - if message.status == "error": - return - - # Extract JSON from response - json_str = self.extract_json_from_text(message.response) - candidate_qualifications = json.loads(json_str) - - # Validate structure - self.validate_candidate_qualifications(candidate_qualifications) - - metadata["results"] = candidate_qualifications["candidate_qualifications"] - message.status = "done" - message.response = json.dumps(candidate_qualifications) - return - - except Exception as e: - metadata["error"] = message.response - yield message - raise - - # Stage 1C: Mapping Analysis Implementation - def create_mapping_analysis_prompt( - self, job_requirements: Dict, candidate_qualifications: Dict - ) -> tuple[str, str]: - """Create the prompt for mapping analysis.""" - system_prompt = """ - You are an objective skills mapper. Your task is to identify legitimate matches between job requirements - and candidate qualifications WITHOUT fabricating or stretching the truth. - - ## INSTRUCTIONS: - - 1. Use ONLY the structured job requirements and candidate qualifications provided. - 2. Create a mapping that shows where the candidate's actual skills and experiences align with job requirements. - 3. Identify gaps where the candidate lacks required qualifications. - 4. Suggest legitimate transferable skills ONLY when there is reasonable evidence. - - ## OUTPUT FORMAT: - - ```json - { - "skills_mapping": { - "direct_matches": [ - { - "job_requirement": "required skill", - "candidate_qualification": "matching skill", - "evidence": "exact quote from candidate materials" - } - ], - "transferable_skills": [ - { - "job_requirement": "required skill", - "candidate_qualification": "transferable skill", - "reasoning": "explanation of legitimate transferability", - "evidence": "exact quote from candidate materials" - } - ], - "gap_analysis": { - "missing_required_skills": ["skill1", "skill2"], - "missing_preferred_skills": ["skill1", "skill2"], - "missing_experience": ["exp1", "exp2"] - } - }, - "resume_recommendations": { - "highlight_points": [ - { - "qualification": "candidate's qualification", - "relevance": "why this is highly relevant to the job" - } - ], - "transferable_narratives": [ - { - "from": "candidate's actual experience", - "to": "job requirement", - "suggested_framing": "how to honestly present the transfer" - } - ], - "honest_limitations": [ - "frank assessment of major qualification gaps" - ] - } - } - ``` - - CRITICAL RULES: - 1. A "direct match" requires the EXACT SAME skill in both job requirements and candidate qualifications - 2. A "transferable skill" must have legitimate, defensible connection - do not stretch credibility - 3. All "missing_required_skills" MUST be acknowledged - do not ignore major gaps - 4. Every match or transfer claim must cite specific evidence from the candidate materials - 5. Be conservative in claiming transferability - when in doubt, list as missing - """ - - prompt = f"Job Requirements:\n{json.dumps(job_requirements, indent=2)}\n\n" - prompt += f"Candidate Qualifications:\n{json.dumps(candidate_qualifications, indent=2)}" - return system_prompt, prompt - - async def create_skills_mapping( - self, - message, - job_requirements: Dict, - candidate_qualifications: Dict, - metadata: Dict[str, Any], - ) -> AsyncGenerator[Message, None]: - """Create mapping between job requirements and candidate qualifications.""" - json_str = "" - try: - system_prompt, prompt = self.create_mapping_analysis_prompt( - job_requirements, candidate_qualifications - ) - metadata["system_prompt"] = system_prompt - metadata["prompt"] = prompt - - async for message in self.call_llm(message, system_prompt, prompt): - if message != "done": - yield message - if message.status == "error": - return - - # Extract JSON from response - json_str = self.extract_json_from_text(message.response) - skills_mapping = json.loads(json_str) - - # Validate structure - self.validate_skills_mapping(skills_mapping) - - metadata["skills_mapping"] = skills_mapping["skills_mapping"] - - message.status = "done" - message.response = json_str - yield message - return - - except Exception as e: - metadata["error"] = json_str - yield message - raise - - # Stage 2: Resume Generation Implementation - def create_resume_generation_prompt( - self, skills_mapping: Dict, candidate_qualifications: Dict, original_header: str - ) -> tuple[str, str]: - """Create the prompt for resume generation.""" - system_prompt = """ - You are a professional resume writer whose primary concern is FACTUAL ACCURACY. Your task is to create - a tailored resume that presents the candidate's actual qualifications in the most relevant way for this job, - using ONLY information that has been verified in the skills mapping. - - ## INSTRUCTIONS: - - 1. Use ONLY the information provided in the skills mapping JSON - 2. Each skill, experience, or achievement you include MUST appear in either "direct_matches" or "transferable_skills" - 3. DO NOT include skills listed in "missing_required_skills" or "missing_preferred_skills" - 4. Format a professional resume with these sections: - - Header with name and contact information (exactly as provided in original resume) - - Professional Summary (focused on verified matching and transferable skills) - - Skills (ONLY from "direct_matches" and "transferable_skills" sections) - - Professional Experience (highlighting experiences referenced in the mapping) - - Education (exactly as listed in the candidate qualifications) - - 5. Follow these principles: - - Use the exact wording from "highlight_points" and "transferable_narratives" when describing experiences - - Maintain original job titles, companies, and dates exactly as provided - - Use achievement-oriented language that emphasizes results and impact - - Prioritize experiences that directly relate to the job requirements - - ## EVIDENCE REQUIREMENT: - - For each skill or qualification you include in the resume, you MUST be able to trace it to: - 1. A specific entry in "direct_matches" or "transferable_skills", AND - 2. The original evidence citation in the candidate qualifications - - If you cannot meet both these requirements for any content, DO NOT include it. - - ## FORMAT REQUIREMENTS: - - - Create a clean, professional resume format - - Use consistent formatting for similar elements - - Ensure readability with appropriate white space - - Use bullet points for skills and achievements - - Include a final note: "Note: Initial draft of the resume was generated using the Backstory application written by James Ketrenos." - - ## FINAL VERIFICATION: - - Before completing the resume: - 1. Check that EVERY skill listed appears in either "direct_matches" or "transferable_skills" - 2. Verify that no skills from "missing_required_skills" are included - 3. Ensure all experience descriptions can be traced to evidence in candidate qualifications - 4. Confirm that transferable skills are presented honestly without exaggeration - """ - - prompt = f"Skills Mapping:\n{json.dumps(skills_mapping, indent=2)}\n\n" - prompt += f"Candidate Qualifications:\n{json.dumps(candidate_qualifications, indent=2)}\n\n" - prompt += f"Original Resume Header:\n{original_header}" - return system_prompt, prompt - - async def generate_tailored_resume( - self, - message, - skills_mapping: Dict, - candidate_qualifications: Dict, - original_header: str, - metadata: Dict[str, Any], - ) -> AsyncGenerator[Message, None]: - """Generate a tailored resume based on skills mapping.""" - system_prompt, prompt = self.create_resume_generation_prompt( - skills_mapping, candidate_qualifications, original_header - ) - metadata["system_prompt"] = system_prompt - metadata["prompt"] = prompt - async for message in self.call_llm( - message, system_prompt, prompt, temperature=0.4 - ): # Slightly higher temperature for better writing - if message.status != "done": - yield message - if message.status == "error": - return - metadata["results"] = message.response - yield message - return - - # Stage 3: Verification Implementation - def create_verification_prompt( - self, - generated_resume: str, - skills_mapping: Dict, - candidate_qualifications: Dict, - ) -> tuple[str, str]: - """Create the prompt for resume verification.""" - system_prompt = """ - You are a critical resume fact-checker responsible for verifying the accuracy of a tailored resume. - Your task is to identify and flag any fabricated or embellished information that does not appear in - the candidate's original materials. - - ## INSTRUCTIONS: - - 1. Compare the tailored resume against: - - The structured skills mapping - - The candidate's original qualifications - - 2. Perform a line-by-line verification focusing on: - - Skills claimed vs. skills verified in original materials - - Experience descriptions vs. actual documented experience - - Projects and achievements vs. documented accomplishments - - Technical knowledge claims vs. verified technical background - - 3. Create a verification report with these sections: - - ## OUTPUT FORMAT: - - ```json - { - "verification_results": { - "factual_accuracy": { - "status": "PASS/FAIL", - "issues": [ - { - "claim": "The specific claim in the resume", - "issue": "Why this is problematic", - "source_check": "Result of checking against source materials", - "suggested_correction": "How to fix this issue" - } - ] - }, - "skill_verification": { - "status": "PASS/FAIL", - "unverified_skills": ["skill1", "skill2"] - }, - "experience_verification": { - "status": "PASS/FAIL", - "problematic_statements": [ - { - "statement": "The problematic experience statement", - "issue": "Why this is problematic", - "suggested_correction": "How to fix this issue" - } - ] - }, - "overall_assessment": "APPROVED/NEEDS REVISION", - "correction_instructions": "Specific instructions for correcting the resume" - } - } - ``` - - ## CRITICAL VERIFICATION CRITERIA: - - 1. Any skill mentioned in the resume MUST appear verbatim in the skills mapping - 2. Any technology experience claimed MUST be explicitly documented in original materials - 3. Role descriptions must not imply expertise with technologies not listed in original materials - 4. "Transferable skills" must be reasonably transferable, not stretches or fabrications - 5. Job titles, dates, and companies must match exactly with original materials - 6. Professional summary must not imply experience with technologies from the job description that aren't in the candidate's background - - ## SPECIAL ATTENTION: - - Pay particular attention to subtle fabrications such as: - - Vague wording that implies experience ("worked with", "familiar with", "utilized") with technologies not in original materials - - Reframing unrelated experience to falsely imply relevance to the job requirements - - Adding technologies to project descriptions that weren't mentioned in the original materials - - Exaggerating level of involvement or responsibility in projects or roles - - Creating achievements that weren't documented in the original materials - """ - - prompt = f"Tailored Resume:\n{generated_resume}\n\n" - prompt += f"Skills Mapping:\n{json.dumps(skills_mapping, indent=2)}\n\n" - prompt += f"Candidate Qualifications:\n{json.dumps(candidate_qualifications, indent=2)}" - return system_prompt, prompt - - async def verify_resume( - self, - message: Message, - generated_resume: str, - skills_mapping: Dict, - candidate_qualifications: Dict, - metadata: Dict[str, Any], - ) -> AsyncGenerator[Message, None]: - """Verify the generated resume for accuracy against original materials.""" - try: - system_prompt, prompt = self.create_verification_prompt( - generated_resume, skills_mapping, candidate_qualifications - ) - metadata["system_prompt"] = system_prompt - metadata["prompt"] = prompt - async for message in self.call_llm(message, system_prompt, prompt): - if message.status != "done": - yield message - if message.status == "error": - return - - # Extract JSON from response - json_str = self.extract_json_from_text(message.response) - metadata["results"] = json.loads(json_str)["verification_results"] - message.status = "done" - message.response = json_str - yield message - return - except Exception as e: - metadata["error"] = message.response - yield message - raise - - async def correct_resume_issues( - self, - message: Message, - generated_resume: str, - verification_results: Dict, - skills_mapping: Dict, - candidate_qualifications: Dict, - original_header: str, - metadata: Dict[str, Any], - ) -> AsyncGenerator[Message, None]: - """Correct issues in the resume based on verification results.""" - if ( - verification_results["verification_results"]["overall_assessment"] - == "APPROVED" - ): - message.status = "done" - message.status = generated_resume - yield message - return - - system_prompt = """\ -You are a professional resume editor with a focus on factual accuracy. Your task is to correct identified issues in a tailored resume according to the verification report. - -## REFERENCE DATA: -The following sections contain reference information for you to use when making corrections. This information should NOT be included in your output resume: - -1. Original Resume - The resume you will correct -2. Verification Results - Issues that need correction -3. Skills Mapping - How candidate skills align with job requirements -4. Candidate Qualifications - Verified information about the candidate's background -5. Original Resume Header - The formatting of the resume header - -## INSTRUCTIONS: - -1. Make ONLY the changes specified in the verification report -2. Ensure all corrections maintain factual accuracy based on the skills mapping -3. Do not introduce any new claims or skills not present in the verification data -4. Maintain the original format and structure of the resume -5. Provide ONLY the fully corrected resume as your output -6. DO NOT include any of the reference data sections in your output -7. DO NOT include any additional comments, explanations, or notes in your output - -## PROCESS: - -1. For each issue in the verification report: - - Identify the problematic text in the resume - - Replace it with the suggested correction - - Ensure the correction is consistent with the rest of the resume - -2. After making all corrections: - - Review the revised resume for consistency - - Ensure no factual inaccuracies have been introduced - - Check that all formatting remains professional - -Your output should contain ONLY the corrected resume text with no additional explanations or context. -""" - prompt = """ -## REFERENCE DATA - -### Original Resume: -""" - prompt += generated_resume - prompt += """ - -### Verification Results: -""" - prompt += json.dumps(verification_results, indent=2) - prompt += """ - -### Skills Mapping: -""" - prompt += json.dumps(skills_mapping, indent=2) - prompt += """ - -### Candidate Qualifications: -""" - prompt += json.dumps(candidate_qualifications, indent=2) - prompt += """ - -### Original Resume Header: -""" - prompt += generated_resume - prompt += """ - -## TASK -Based on the reference data above, please create a corrected version of the resume that addresses all issues identified in the verification report. Return ONLY the corrected resume. -""" - - metadata["system_prompt"] = system_prompt - metadata["prompt"] = prompt - - async for message in self.call_llm( - message, prompt, system_prompt, temperature=0.3 - ): - if message.status != "done": - yield message - if message.status == "error": - return - metadata["results"] = message.response - yield message - - def process_job_requirements2( - self, job_requirements: Dict[str, Any] - ) -> List[Dict[str, Any]]: - """ - Process job requirements JSON, gather RAG documents using find_similar, remove duplicates, - and return top 20 results. - - Args: - job_requirements: Dictionary containing job requirements. - retriever: Instance of RagRetriever with find_similar method. - - Returns: - List of up to 20 ChromaDB documents, sorted by combined importance and similarity score. - """ - if self.context is None or self.context.file_watcher is None: - raise ValueError(f"context or file_watcher is None on {self.agent_type}") - - retriever = self.context.file_watcher - # Importance weights for each category - importance_weights = { - ("technical_skills", "required"): 1.0, - ("technical_skills", "preferred"): 0.8, - ("experience_requirements", "required"): 0.95, - ("experience_requirements", "preferred"): 0.75, - ("education_requirements", ""): 0.7, - ("soft_skills", ""): 0.6, - ("industry_knowledge", ""): 0.65, - ("responsibilities", ""): 0.85, - ("company_values", ""): 0.5, - } - - # Store all RAG results with metadata - all_results = [] - - def traverse_requirements(data: Any, category: str = "", subcategory: str = ""): - """ - Recursively traverse the job requirements and gather RAG documents. - """ - if isinstance(data, dict): - for key, value in data.items(): - new_subcategory = key if category else "" - traverse_requirements(value, category or key, new_subcategory) - elif isinstance(data, list): - for item in data: - # Determine the weight key - weight_key = ( - (category, subcategory) if subcategory else (category, "") - ) - weight = importance_weights.get(weight_key, 0.5) # Default weight - - # Call find_similar for the item - try: - rag_results = retriever.find_similar( - item, top_k=20, threshold=0.5 - ) # Moderate matching - # Process each result - for doc_id, content, distance, metadata in zip( - rag_results["ids"], - rag_results["documents"], - rag_results["distances"], - rag_results["metadatas"], - ): - # Convert cosine distance to similarity score (higher is better) - similarity_score = ( - 1 - distance - ) # Cosine distance to similarity - all_results.append( - { - "id": doc_id, - "content": content, - "score": similarity_score, - "weight": weight, - "context": item, - "category": category, - "subcategory": subcategory, - "metadata": metadata, - } - ) - except Exception as e: - logger.error(f"Error processing context '{item}': {e}") - - # Start traversal - traverse_requirements(job_requirements) - - # Remove duplicates based on document ID - unique_results = [] - seen_ids = set() - for result in all_results: - if result["id"] not in seen_ids: - seen_ids.add(result["id"]) - unique_results.append(result) - - # Sort by combined score (weight * similarity score) - sorted_results = sorted( - unique_results, key=lambda x: x["weight"] * x["score"], reverse=True - ) - - # Return top 10 results - return sorted_results[:10] - - async def generate_rag_content( - self, message: Message, job_requirements: Dict[str, Any] - ) -> AsyncGenerator[Message, None]: - results = self.process_job_requirements2(job_requirements=job_requirements) - message.response = f"Retrieved {len(results)} documents." - message.metadata["rag_context"] = results - # for result in results: - # message.response += f""" - # ID: {result['id']}, Context: {result['context']}, \ - # Category: {result['category']}/{result['subcategory']}, \ - # Similarity Score: {result['score']:.3f}, \ - # Combined Score: {result['weight'] * result['score']:.3f}, \ - # Content: {result['content']} - # """ - message.status = "done" - yield message - return - def rag_function(self, skill: str) -> tuple[str, list[Any]]: if self.context is None or self.context.file_watcher is None: raise ValueError("self.context or self.context.file_watcher is None") @@ -1787,7 +1065,6 @@ Content: { content } } # Extract header from original resume - original_header = self.extract_header_from_resume(resume) candidate_info = { "name": "James Ketrenos", "contact_info": { @@ -1819,81 +1096,5 @@ Content: { content } yield message return - # Stage 3: Verify resume - message.status = "thinking" - message.response = "Multi-stage RAG resume generation process: Stage 3: Verifying resume for accuracy" - logger.info(message.response) - yield message - metadata["verify_resume"] = {"first_pass": {}} - async for message in self.verify_resume( - message=message, - generated_resume=generated_resume, - skills_mapping=skills_mapping, - candidate_qualifications=candidate_qualifications, - metadata=metadata["verify_resume"]["first_pass"], - ): - if message.status != "done": - yield message - if message.status == "error": - return - - verification_results = json.loads(message.response) - - # Handle corrections if needed - if ( - verification_results["verification_results"]["overall_assessment"] - == "NEEDS REVISION" - ): - message.status = "thinking" - message.response = "Correcting issues found in verification" - logger.info(message.response) - yield message - - metadata["correct_resume_issues"] = {} - async for message in self.correct_resume_issues( - message=message, - generated_resume=generated_resume, - verification_results=verification_results, - skills_mapping=skills_mapping, - candidate_qualifications=candidate_qualifications, - original_header=original_header, - metadata=metadata["correct_resume_issues"], - ): - if message.status != "done": - yield message - if message.status == "error": - return - - generated_resume = message.response - - # Re-verify after corrections - message.status = "thinking" - message.response = "Re-verifying corrected resume" - yield message - - logger.info(message.response) - metadata["verify_resume"]["second_pass"] = {} - async for message in self.verify_resume( - message=message, - generated_resume=generated_resume, - skills_mapping=skills_mapping, - candidate_qualifications=candidate_qualifications, - metadata=metadata["verify_resume"]["second_pass"], - ): - if message.status != "done": - yield message - if message.status == "error": - return - verification_results = json.loads(message.response) - - # Return the final results - message.status = "done" - message.response = generated_resume - yield message - - logger.info("Resume generation process completed successfully") - return - - # Register the base agent agent_registry.register(JobDescription._agent_type, JobDescription) diff --git a/src/utils/context.py b/src/utils/context.py index 94ae19d..c22f6b8 100644 --- a/src/utils/context.py +++ b/src/utils/context.py @@ -176,6 +176,7 @@ class Context(BaseModel): raise ValueError(f"No agent class found for agent_type: {agent_type}") + @classmethod def add_agent(self, agent: AnyAgent) -> None: """Add a Agent to the context, ensuring no duplicate agent_type.""" if any(s.agent_type == agent.agent_type for s in self.agents): diff --git a/src/utils/conversation.py b/src/utils/conversation.py index ff8ffe2..20798b4 100644 --- a/src/utils/conversation.py +++ b/src/utils/conversation.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field, PrivateAttr # type: ignore +from pydantic import BaseModel, Field # type: ignore from typing import List from .message import Message diff --git a/src/utils/markdown_chunker.py b/src/utils/markdown_chunker.py new file mode 100644 index 0000000..47c42ec --- /dev/null +++ b/src/utils/markdown_chunker.py @@ -0,0 +1,212 @@ +from typing import List, Dict, Any, Optional, TypedDict, Tuple +from markdown_it import MarkdownIt +from markdown_it.tree import SyntaxTreeNode +import traceback +import logging +import json + + +class Chunk(TypedDict): + text: str + metadata: Dict[str, Any] + + +def clear_chunk(chunk: Chunk): + chunk["text"] = "" + chunk["metadata"] = { + "doc_type": "unknown", + "source_file": chunk["metadata"]["source_file"], + "path": "", # This will be updated during processing + "level": 0, + } + return chunk + + +class MarkdownChunker: + def __init__(self): + # Initialize the Markdown parser + self.md_parser = MarkdownIt("commonmark") + + def process_file(self, file_path: str) -> Optional[List[Chunk]]: + """ + Process a single markdown file and return chunks. + + Args: + file_path: Path to the markdown file + + Returns: + List of chunks with metadata or None if file can't be processed + """ + try: + # Read the markdown file + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Parse the markdown + tokens = self.md_parser.parse(content) + + ast = SyntaxTreeNode(tokens) + + # Extract chunks with metadata + chunks = self.extract_chunks(ast, file_path) + + return chunks + + except Exception as e: + logging.error(f"Error processing {file_path}: {str(e)}") + logging.error(traceback.format_exc()) + + return None + + def extract_chunks(self, ast: SyntaxTreeNode, file_path: str) -> List[Chunk]: + """ + Extract logical chunks from the AST with appropriate metadata. + + Args: + ast: The Abstract Syntax Tree from markdown-it-py + file_path: Path to the source file + + Returns: + List of chunks with metadata + """ + chunks: List[Chunk] = [] + current_headings: List[str] = [""] * 6 # Track h1-h6 headings + + # Initialize a chunk structure + chunk: Chunk = { + "text": "", + "metadata": { + "source_file": file_path, + }, + } + clear_chunk(chunk) + + # Process the AST recursively + self._process_node(ast, current_headings, chunks, chunk, level=0) + + return chunks + + def _sanitize_metadata(self, metadata: Dict[str, Any]) -> Dict[str, Any]: + return { + k: ("" if v is None else v) for k, v in metadata.items() if v is not None + } + + def _extract_text_from_children(self, node: SyntaxTreeNode) -> str: + lines = [] + for child in node.children: + if child.type == "list_item": + lines.append(f"- {self._extract_text_from_children(child).strip()}") + elif child.type == "fence": + info = f"{child.info}" if hasattr(child, "info") and child.info else "" + lines.append(f"\n```{info}\n{child.content.strip()}\n```\n") + elif child.children: + lines.append(self._extract_text_from_children(child).strip()) + elif hasattr(child, "content"): + lines.append(child.content.strip()) + return "\n".join(lines) + + def _process_node( + self, + node: SyntaxTreeNode, + current_headings: list[str], + chunks: List[Chunk], + chunk: Chunk, + level: int, + ) -> int: + is_list = False + # Handle heading nodes + if node.type == "heading": + # print(f'{" " * level}{chunk["metadata"]["path"]} {node.type}') + if node.is_nested and node.nester_tokens: + opening, closing = node.nester_tokens + level = int(opening.tag[1:]) - 1 + + heading_text = self._extract_text_from_children(node) + + # Update lower heading states + current_headings[level] = heading_text + for i in range(level, 6): + if i != level: + current_headings[i] = "" + + # Store previous chunk if it has text + if chunk["text"].strip(): + path = " > ".join([h for h in current_headings if h]) + chunk["text"] = chunk["text"].strip() + chunk["metadata"]["path"] = path + chunk["metadata"]["level"] = level + if node.nester_tokens: + opening, closing = node.nester_tokens + if opening and opening.map: + ( + chunk["metadata"]["line_begin"], + chunk["metadata"]["line_end"], + ) = opening.map + chunks.append(chunk.copy()) + clear_chunk(chunk) + + # Add code block directly to current chunk + elif node.type == "fence": + # print(f'{" " * level}{chunk["metadata"]["path"]} {node.type}') + language = node.info.strip() if hasattr(node, "info") and node.info else "" + code_block = f"\n```{language}\n{node.content.strip()}\n```\n" + chunk["text"] += code_block + + # Handle list structures + # elif node.type in ["list", "list_item"]: + # # print(node.nester_tokens) + # # print(node.pretty(show_text=True)) + # is_list = True + # list_chunk = [] + # for child in node.children: + # level = self._process_node(child, current_headings, chunks, chunk, level=level) + + # Handle paragraph + elif node.type in ["paragraph"]: # , "list", "list_item"]: + text = self._extract_text_from_children(node) + # indented_text = "\n".join([f'{"-" * (level + 2)}{line}' for line in text.split('\n')]) + # print(f'{"-" * level}{chunk["metadata"]["path"]} {node.type}:\n{indented_text} {len(node.children)}\n') + text = text.strip() + if text: + # indented_text = "\n".join([f'{"-" * (level + 2)}{line}' for line in text.split('\n')]) + # print(f'{" " * level}{chunk["metadata"]["path"]} {node.type}:\n{indented_text}\n') + chunk["text"] += f"\n{text}\n" + chunk["text"] = chunk["text"].strip() + if chunk["text"]: + path = " > ".join([h for h in current_headings if h]) + chunk["text"] = chunk["text"] + chunk["metadata"]["path"] = path + chunk["metadata"]["level"] = level + if node.nester_tokens: + opening, closing = node.nester_tokens + if opening and opening.map: + ( + chunk["metadata"]["line_begin"], + chunk["metadata"]["line_end"], + ) = opening.map + chunks.append(chunk.copy()) + clear_chunk(chunk) + + # Recursively process children + if not is_list: + for child in node.children: + level = self._process_node( + child, current_headings, chunks, chunk, level=level + ) + + # After root-level recursion, finalize any remaining chunk + if node.type == "document": + # print(node.type) + if chunk["text"].strip(): + path = " > ".join([h for h in current_headings if h]) + chunk["metadata"]["path"] = path + if node.nester_tokens: + opening, closing = node.nester_tokens + if opening and opening.map: + ( + chunk["metadata"]["line_begin"], + chunk["metadata"]["line_end"], + ) = opening.map + chunks.append(chunk.copy()) + + return level diff --git a/src/utils/message.py b/src/utils/message.py index 5b0a80f..507d2e2 100644 --- a/src/utils/message.py +++ b/src/utils/message.py @@ -1,8 +1,6 @@ from pydantic import BaseModel, Field # type: ignore from typing import Dict, List, Optional, Any from datetime import datetime, timezone -from asyncio import Event - class Tunables(BaseModel): enable_rag: bool = Field(default=True) # Enable RAG collection chromadb matching diff --git a/src/utils/metrics.py b/src/utils/metrics.py index 3d3b111..6f30521 100644 --- a/src/utils/metrics.py +++ b/src/utils/metrics.py @@ -1,7 +1,6 @@ -from prometheus_client import Counter, Gauge, Summary, Histogram, Info, Enum, CollectorRegistry # type: ignore +from prometheus_client import Counter, Histogram # type: ignore from threading import Lock - def singleton(cls): instance = None lock = Lock() diff --git a/src/utils/rag.py b/src/utils/rag.py index 9e26737..0fc4364 100644 --- a/src/utils/rag.py +++ b/src/utils/rag.py @@ -21,7 +21,7 @@ import chromadb import ollama from watchdog.observers import Observer # type: ignore from watchdog.events import FileSystemEventHandler # type: ignore -import umap # type: ignore +import umap # type: ignore from markitdown import MarkItDown # type: ignore from chromadb.api.models.Collection import Collection # type: ignore