Added dynamic QR code generation for job-analysis
This commit is contained in:
parent
cf5936730c
commit
b130cd3974
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "ai.ketrenos.com",
|
||||
"name": "Ketrenos AI Chat",
|
||||
"short_name": "backstory.ketrenos.com",
|
||||
"name": "Backstory",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
@ -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 }}> </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>
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user