Added dynamic QR code generation for job-analysis

This commit is contained in:
James Ketr 2025-07-08 18:01:38 -07:00
parent cf5936730c
commit b130cd3974
7 changed files with 126 additions and 104 deletions

View File

@ -294,17 +294,16 @@ RUN apt-get update \
WORKDIR /opt/ollama WORKDIR /opt/ollama
# Download the nightly ollama release from ipex-llm # Download the nightly ollama release from ipex-llm
#ENV OLLAMA_VERSION=https://github.com/intel/ipex-llm/releases/download/v2.2.0/ollama-ipex-llm-2.2.0-ubuntu.tgz
#ENV OLLAMA_VERSION=https://github.com/intel/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250415-ubuntu.tgz
# NOTE: NO longer at github.com/intel -- now at ipex-llm # NOTE: NO longer at github.com/intel -- now at ipex-llm
# This version does not work: # This version does not work:
# ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250429-ubuntu.tgz
ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.2.0/ollama-ipex-llm-2.2.0-ubuntu.tgz ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.2.0/ollama-ipex-llm-2.2.0-ubuntu.tgz
#ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250429-ubuntu.tgz
# Does not work -- crashes
# ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250612-ubuntu.tgz
RUN wget -qO - ${OLLAMA_VERSION} | \ RUN wget -qO - ${OLLAMA_VERSION} | \
tar --strip-components=1 -C . -xzv tar --strip-components=1 -C . -xzv

View File

@ -4,8 +4,8 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
target: backstory target: backstory
container_name: backstory
#image: backstory #image: backstory
container_name: backstory
restart: "always" restart: "always"
env_file: env_file:
- .env - .env
@ -15,6 +15,7 @@ services:
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b} - MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- REDIS_DB=0 - REDIS_DB=0
- SSL_ENABLED=true
devices: devices:
- /dev/dri:/dev/dri - /dev/dri:/dev/dri
depends_on: depends_on:
@ -50,6 +51,7 @@ services:
- .env - .env
environment: environment:
- PRODUCTION=1 - PRODUCTION=1
- FRONTEND_URL=https://backstory.ketrenos.com
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b} - MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- REDIS_DB=1 - REDIS_DB=1
@ -58,6 +60,7 @@ services:
- /dev/dri:/dev/dri - /dev/dri:/dev/dri
depends_on: depends_on:
- ollama - ollama
- redis
networks: networks:
- internal - internal
ports: ports:

View File

@ -1,6 +1,6 @@
{ {
"short_name": "ai.ketrenos.com", "short_name": "backstory.ketrenos.com",
"name": "Ketrenos AI Chat", "name": "Backstory",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.ico",

View File

@ -55,7 +55,7 @@ import { parsePhoneNumberFromString } from 'libphonenumber-js';
import { useReactToPrint } from 'react-to-print'; import { useReactToPrint } from 'react-to-print';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext'; import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
import { StyledMarkdown } from 'components/StyledMarkdown'; import { StyledMarkdown } from 'components/StyledMarkdown';
import { Resume } from 'types/types'; import { Resume } from 'types/types';
import { BackstoryTextField } from 'components/BackstoryTextField'; import { BackstoryTextField } from 'components/BackstoryTextField';
@ -355,10 +355,11 @@ const resumeStyles: Record<string, ResumeStyle> = {
// Styled Header Component // Styled Header Component
interface BackstoryStyledResumeProps { interface BackstoryStyledResumeProps {
candidate: Types.Candidate; candidate: Types.Candidate;
job?: Types.Job;
style: ResumeStyle; style: ResumeStyle;
} }
const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }) => { const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, job, style }) => {
return ( return (
<> <>
<Box <Box
@ -378,16 +379,16 @@ const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }
color: style.color.secondary, color: style.color.secondary,
}} }}
> >
Ask any questions you may have at my Backstory... Dive deeper into my qualifications at Backstory...
<Box <Box
component="img" component="img"
src={`/api/1.0/candidates/qr-code/${candidate.id || ''}`} src={`/api/1.0/candidates/qr-code/${candidate.id || ''}/${(job && job.id) || ''}`}
alt="QR Code" alt="QR Code"
className="qr-code" className="qr-code"
sx={{ display: 'flex', mt: 1, mb: 1 }} sx={{ display: 'flex', mt: 1, mb: 1 }}
/> />
{candidate?.username {candidate?.username
? `https://backstory.ketrenos.com/u/${candidate?.username}` ? `${window.location.protocol}://${window.location.host}/u/${candidate?.username}`
: 'backstory'} : 'backstory'}
</Box> </Box>
<Box sx={{ pb: 2 }}>&nbsp;</Box> <Box sx={{ pb: 2 }}>&nbsp;</Box>
@ -537,7 +538,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null); const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
const [error, setError] = useState<Types.ChatMessageError | null>(null); const [error, setError] = useState<Types.ChatMessageError | null>(null);
const [selectedStyle, setSelectedStyle] = useState<string>('modern'); const [selectedStyle, setSelectedStyle] = useState<string>('corporate');
const printContentRef = useRef<HTMLDivElement>(null); const printContentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({ const reactToPrintFn = useReactToPrint({
@ -785,13 +786,11 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
avatar={<DescriptionIcon color="success" />} avatar={<DescriptionIcon color="success" />}
sx={{ p: 0, pb: 1 }} sx={{ p: 0, pb: 1 }}
action={ action={
isAdmin && (
<Tooltip title="Edit Resume Content"> <Tooltip title="Edit Resume Content">
<IconButton size="small" onClick={handleEditOpen}> <IconButton size="small" onClick={handleEditOpen}>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)
} }
/> />
<CardContent sx={{ p: 0 }}> <CardContent sx={{ p: 0 }}>
@ -899,6 +898,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
fullScreen={true} fullScreen={true}
> >
<DialogTitle> <DialogTitle>
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, m: 0, p: 0 }}>
Edit Resume Content Edit Resume Content
<Typography variant="caption" display="block" color="text.secondary"> <Typography variant="caption" display="block" color="text.secondary">
Resume for {activeResume.candidate?.fullName || activeResume.candidateId},{' '} Resume for {activeResume.candidate?.fullName || activeResume.candidateId},{' '}
@ -912,6 +913,61 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
Last saved:{' '} Last saved:{' '}
{activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'} {activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'}
</Typography> </Typography>
</Box>
<Box
sx={{
display: 'flex',
flexGrow: 1,
flexDirection: 'row',
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{/* Style Selector */}
<FormControl size="small" sx={{ minWidth: 'min-content' }}>
<InputLabel id="resume-style-label">Resume Style</InputLabel>
<Select
labelId="resume-style-label"
value={selectedStyle}
onChange={e => setSelectedStyle(e.target.value)}
label="Resume Style"
>
{Object.entries(resumeStyles).map(([key, style]) => (
<MenuItem key={key} value={key}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography variant="body2" fontWeight="bold">
{style.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{style.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
{activeResume.systemPrompt && (
<Tab value="systemPrompt" icon={<TuneIcon />} label="System Prompt" />
)}
{activeResume.systemPrompt && (
<Tab value="prompt" icon={<InputIcon />} label="Prompt" />
)}
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab value="print" icon={<PrintIcon />} label="Print" />
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
</Tabs>
</Box>
</Box>
</DialogTitle> </DialogTitle>
<DialogContent <DialogContent
sx={{ sx={{
@ -945,65 +1001,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
{activeResume.systemPrompt && (
<Tab value="systemPrompt" icon={<TuneIcon />} label="System Prompt" />
)}
{activeResume.systemPrompt && (
<Tab value="prompt" icon={<InputIcon />} label="Prompt" />
)}
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab value="print" icon={<PrintIcon />} label="Print" />
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
</Tabs>
{/* Style Selector */}
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id="resume-style-label">
<StyleIcon sx={{ mr: 1, fontSize: 16 }} />
Resume Style
</InputLabel>
<Select
labelId="resume-style-label"
value={selectedStyle}
onChange={e => setSelectedStyle(e.target.value)}
label="Resume Style"
>
{Object.entries(resumeStyles).map(([key, style]) => (
<MenuItem key={key} value={key}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography variant="body2" fontWeight="bold">
{style.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{style.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
{/* Style Preview Chip */}
<Chip
icon={<StyleIcon />}
label={currentStyle.name}
sx={{
backgroundColor: currentStyle.color.primary,
color: '#ffffff',
fontWeight: 'bold',
}}
/>
</Box>
{status && ( {status && (
<Box sx={{ mt: 0, mb: 1 }}> <Box sx={{ mt: 0, mb: 1 }}>
<StatusBox> <StatusBox>
@ -1117,8 +1114,12 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Box> </Box>
{/* QR Code Footer */} {/* QR Code Footer */}
{activeResume.candidate && ( {activeResume.candidate && activeResume.job && (
<StyledFooter candidate={activeResume.candidate} style={currentStyle} /> <StyledFooter
candidate={activeResume.candidate}
job={activeResume.job}
style={currentStyle}
/>
)} )}
</Box> </Box>
</Box> </Box>

View File

@ -2,6 +2,8 @@ import os
ollama_api_url = "http://ollama:11434" # Default Ollama local endpoint ollama_api_url = "http://ollama:11434" # Default Ollama local endpoint
frontend_url = os.getenv("FRONTEND_URL", "https://backstory.ketrenos.com")
user_dir = "/opt/backstory/users" user_dir = "/opt/backstory/users"
user_info_file = "info.json" # Relative to "{user_dir}/{user}" user_info_file = "info.json" # Relative to "{user_dir}/{user}"
default_username = "jketreno" default_username = "jketreno"
@ -16,8 +18,8 @@ persist_directory = "db" # Relative to "{user_dir}/{user}"
# model = "gemma3:4b" # Gemma Requires newer ollama https://ai.google.dev/gemma/terms # model = "gemma3:4b" # Gemma Requires newer ollama https://ai.google.dev/gemma/terms
# model = "llama3.2" # Llama Good results; qwen seems slightly better https://huggingface.co/meta-llama/Llama-3.2-1B/blob/main/LICENSE.txt # model = "llama3.2" # Llama Good results; qwen seems slightly better https://huggingface.co/meta-llama/Llama-3.2-1B/blob/main/LICENSE.txt
# model = "mistral:7b" # Apache 2.0 Tool calls don"t work # model = "mistral:7b" # Apache 2.0 Tool calls don"t work
model = "qwen2.5:7b" # Apache 2.0 Good results
# model = "qwen3:8b" # Apache 2.0 Requires newer ollama # model = "qwen3:8b" # Apache 2.0 Requires newer ollama
model = "qwen2.5:7b" # Apache 2.0 Good results
model = os.getenv("MODEL_NAME", model) model = os.getenv("MODEL_NAME", model)
# Embedding model for producing vectors to use in RAG # Embedding model for producing vectors to use in RAG

View File

@ -1170,7 +1170,7 @@ async def verify_email(
dir_path = os.path.join(defines.user_dir, username) dir_path = os.path.join(defines.user_dir, username)
if not os.path.exists(dir_path): if not os.path.exists(dir_path):
os.makedirs(dir_path, exist_ok=True) os.makedirs(dir_path, exist_ok=True)
qrobj = pyqrcode.create(f"https://backstory.ketrenos.com/u/{username}") qrobj = pyqrcode.create(f"{defines.frontend_url}/u/{username}")
dir_path = os.path.join(dir_path, "qrcode.png") dir_path = os.path.join(dir_path, "qrcode.png")
with open(dir_path, "wb") as f: with open(dir_path, "wb") as f:
qrobj.png(f, scale=2) qrobj.png(f, scale=2)

View File

@ -714,9 +714,10 @@ async def get_candidate_profile_image(
status_code=500, content=create_error_response("FETCH_ERROR", "Failed to retrieve profile image") status_code=500, content=create_error_response("FETCH_ERROR", "Failed to retrieve profile image")
) )
@router.get("/qr-code/{candidate_id}") @router.get("/qr-code/{candidate_id}/{job_id}")
async def get_candidate_qr_code( async def get_candidate_qr_code(
candidate_id: str = Path(..., description="ID of the candidate"), candidate_id: str = Path(..., description="ID of the candidate"),
job_id: Optional[str] = Path(..., description="ID of the candidate"),
# current_user = Depends(get_current_user), # current_user = Depends(get_current_user),
database: RedisDatabase = Depends(get_database), database: RedisDatabase = Depends(get_database),
): ):
@ -735,13 +736,29 @@ async def get_candidate_qr_code(
return JSONResponse(status_code=404, content=create_error_response("NOT_FOUND", "Candidate not found")) return JSONResponse(status_code=404, content=create_error_response("NOT_FOUND", "Candidate not found"))
candidate = Candidate.model_validate(candidates_list[0]) candidate = Candidate.model_validate(candidates_list[0])
file_path = os.path.join(defines.user_dir, candidate.username, "qrcode.png")
file_path = pathlib.Path(file_path) job = None
if not file_path.exists(): if job_id:
logger.error(f"❌ QR code not found on disk: {file_path}") job_data = await database.get_job(job_id)
if not job_data:
logger.warning(f"⚠️ Job not found for ID: {job_id}")
return JSONResponse( return JSONResponse(
status_code=404, content=create_error_response("FILE_NOT_FOUND", "QR code not found on disk") status_code=404,
content=create_error_response("JOB_NOT_FOUND", f"Job with id '{job_id}' not found"),
) )
job = Job.model_validate(job_data)
file_name = f"{job.id}.png" if job else "qrcode.png"
file_path = pathlib.Path(defines.user_dir) / candidate.username / file_name
if not file_path.exists():
import pyqrcode
if job:
qrobj = pyqrcode.create(f"{defines.frontend_url}/job-analysis/{candidate.id}/{job.id}")
else:
qrobj = pyqrcode.create(f"{defines.frontend_url}/u/{candidate.id}")
with open(file_path, "wb") as f:
qrobj.png(f, scale=2)
return FileResponse( return FileResponse(
file_path, file_path,
media_type=f"image/{file_path.suffix[1:]}", # Get extension without dot media_type=f"image/{file_path.suffix[1:]}", # Get extension without dot