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
# 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
# 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.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} | \
tar --strip-components=1 -C . -xzv

View File

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

View File

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

View File

@ -55,7 +55,7 @@ import { parsePhoneNumberFromString } from 'libphonenumber-js';
import { useReactToPrint } from 'react-to-print';
import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext';
import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
import { StyledMarkdown } from 'components/StyledMarkdown';
import { Resume } from 'types/types';
import { BackstoryTextField } from 'components/BackstoryTextField';
@ -355,10 +355,11 @@ const resumeStyles: Record<string, ResumeStyle> = {
// Styled Header Component
interface BackstoryStyledResumeProps {
candidate: Types.Candidate;
job?: Types.Job;
style: ResumeStyle;
}
const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }) => {
const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, job, style }) => {
return (
<>
<Box
@ -378,16 +379,16 @@ const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }
color: style.color.secondary,
}}
>
Ask any questions you may have at my Backstory...
Dive deeper into my qualifications at Backstory...
<Box
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"
className="qr-code"
sx={{ display: 'flex', mt: 1, mb: 1 }}
/>
{candidate?.username
? `https://backstory.ketrenos.com/u/${candidate?.username}`
? `${window.location.protocol}://${window.location.host}/u/${candidate?.username}`
: 'backstory'}
</Box>
<Box sx={{ pb: 2 }}>&nbsp;</Box>
@ -537,7 +538,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const [status, setStatus] = useState<string>('');
const [statusType, setStatusType] = useState<Types.ApiActivityType | 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 reactToPrintFn = useReactToPrint({
@ -785,13 +786,11 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
avatar={<DescriptionIcon color="success" />}
sx={{ p: 0, pb: 1 }}
action={
isAdmin && (
<Tooltip title="Edit Resume Content">
<IconButton size="small" onClick={handleEditOpen}>
<EditIcon />
</IconButton>
</Tooltip>
)
<Tooltip title="Edit Resume Content">
<IconButton size="small" onClick={handleEditOpen}>
<EditIcon />
</IconButton>
</Tooltip>
}
/>
<CardContent sx={{ p: 0 }}>
@ -899,19 +898,76 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
fullScreen={true}
>
<DialogTitle>
Edit Resume Content
<Typography variant="caption" display="block" color="text.secondary">
Resume for {activeResume.candidate?.fullName || activeResume.candidateId},{' '}
{activeResume.job?.title || 'No Job Title Assigned'},{' '}
{activeResume.job?.company || 'No Company Assigned'}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Resume ID: # {activeResume.id}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Last saved:{' '}
{activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, m: 0, p: 0 }}>
Edit Resume Content
<Typography variant="caption" display="block" color="text.secondary">
Resume for {activeResume.candidate?.fullName || activeResume.candidateId},{' '}
{activeResume.job?.title || 'No Job Title Assigned'},{' '}
{activeResume.job?.company || 'No Company Assigned'}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Resume ID: # {activeResume.id}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Last saved:{' '}
{activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'}
</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>
<DialogContent
sx={{
@ -945,65 +1001,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
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 && (
<Box sx={{ mt: 0, mb: 1 }}>
<StatusBox>
@ -1117,8 +1114,12 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Box>
{/* QR Code Footer */}
{activeResume.candidate && (
<StyledFooter candidate={activeResume.candidate} style={currentStyle} />
{activeResume.candidate && activeResume.job && (
<StyledFooter
candidate={activeResume.candidate}
job={activeResume.job}
style={currentStyle}
/>
)}
</Box>
</Box>

View File

@ -2,6 +2,8 @@ import os
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_info_file = "info.json" # Relative to "{user_dir}/{user}"
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 = "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 = "qwen3:8b" # Apache 2.0 Requires newer ollama
model = "qwen2.5:7b" # Apache 2.0 Good results
# model = "qwen3:8b" # Apache 2.0 Requires newer ollama
model = os.getenv("MODEL_NAME", model)
# 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)
if not os.path.exists(dir_path):
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")
with open(dir_path, "wb") as f:
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")
)
@router.get("/qr-code/{candidate_id}")
@router.get("/qr-code/{candidate_id}/{job_id}")
async def get_candidate_qr_code(
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),
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"))
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 job_id:
job_data = await database.get_job(job_id)
if not job_data:
logger.warning(f"⚠️ Job not found for ID: {job_id}")
return JSONResponse(
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():
logger.error(f"❌ QR code not found on disk: {file_path}")
return JSONResponse(
status_code=404, content=create_error_response("FILE_NOT_FOUND", "QR code not found on disk")
)
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(
file_path,
media_type=f"image/{file_path.suffix[1:]}", # Get extension without dot