ai chooses projects
This commit is contained in:
parent
3b4845c232
commit
43f54a708c
@ -10,6 +10,7 @@ import type {
|
|||||||
JobSource,
|
JobSource,
|
||||||
PipelineRun,
|
PipelineRun,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
|
ResumeProjectsSettings,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
|
|
||||||
const API_BASE = '/api';
|
const API_BASE = '/api';
|
||||||
@ -102,6 +103,7 @@ export async function updateSettings(update: {
|
|||||||
model?: string | null
|
model?: string | null
|
||||||
pipelineWebhookUrl?: string | null
|
pipelineWebhookUrl?: string | null
|
||||||
jobCompleteWebhookUrl?: string | null
|
jobCompleteWebhookUrl?: string | null
|
||||||
|
resumeProjects?: ResumeProjectsSettings | null
|
||||||
}): Promise<AppSettings> {
|
}): Promise<AppSettings> {
|
||||||
return fetchApi<AppSettings>('/settings', {
|
return fetchApi<AppSettings>('/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@ -7,16 +7,41 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import type { AppSettings } from "../../shared/types"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import type { AppSettings, ResumeProjectsSettings } from "../../shared/types"
|
||||||
import * as api from "../api"
|
import * as api from "../api"
|
||||||
|
|
||||||
|
function arraysEqual(a: string[], b: string[]) {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) {
|
||||||
|
return (
|
||||||
|
a.maxProjects === b.maxProjects &&
|
||||||
|
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
|
||||||
|
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(value: number, min: number, max: number) {
|
||||||
|
const int = Math.floor(value)
|
||||||
|
if (Number.isNaN(int)) return min
|
||||||
|
return Math.min(max, Math.max(min, int))
|
||||||
|
}
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||||
const [modelDraft, setModelDraft] = useState("")
|
const [modelDraft, setModelDraft] = useState("")
|
||||||
const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("")
|
const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("")
|
||||||
const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("")
|
const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("")
|
||||||
|
const [resumeProjectsDraft, setResumeProjectsDraft] = useState<ResumeProjectsSettings | null>(null)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
@ -31,6 +56,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setModelDraft(data.overrideModel ?? "")
|
setModelDraft(data.overrideModel ?? "")
|
||||||
setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "")
|
setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "")
|
||||||
setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "")
|
setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "")
|
||||||
|
setResumeProjectsDraft(data.resumeProjects)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load settings"
|
const message = error instanceof Error ? error.message : "Failed to load settings"
|
||||||
@ -55,9 +81,12 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? ""
|
const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? ""
|
||||||
const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? ""
|
const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? ""
|
||||||
const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl
|
const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl
|
||||||
|
const profileProjects = settings?.profileProjects ?? []
|
||||||
|
const maxProjectsTotal = profileProjects.length
|
||||||
|
const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0
|
||||||
|
|
||||||
const canSave = useMemo(() => {
|
const canSave = useMemo(() => {
|
||||||
if (!settings) return false
|
if (!settings || !resumeProjectsDraft) return false
|
||||||
const next = modelDraft.trim()
|
const next = modelDraft.trim()
|
||||||
const current = (overrideModel ?? "").trim()
|
const current = (overrideModel ?? "").trim()
|
||||||
const nextWebhook = pipelineWebhookUrlDraft.trim()
|
const nextWebhook = pipelineWebhookUrlDraft.trim()
|
||||||
@ -67,7 +96,8 @@ export const SettingsPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
next !== current ||
|
next !== current ||
|
||||||
nextWebhook !== currentWebhook ||
|
nextWebhook !== currentWebhook ||
|
||||||
nextJobCompleteWebhook !== currentJobCompleteWebhook
|
nextJobCompleteWebhook !== currentJobCompleteWebhook ||
|
||||||
|
!resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects)
|
||||||
)
|
)
|
||||||
}, [
|
}, [
|
||||||
settings,
|
settings,
|
||||||
@ -77,24 +107,30 @@ export const SettingsPage: React.FC = () => {
|
|||||||
overrideModel,
|
overrideModel,
|
||||||
overridePipelineWebhookUrl,
|
overridePipelineWebhookUrl,
|
||||||
overrideJobCompleteWebhookUrl,
|
overrideJobCompleteWebhookUrl,
|
||||||
|
resumeProjectsDraft,
|
||||||
])
|
])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!settings) return
|
if (!settings || !resumeProjectsDraft) return
|
||||||
try {
|
try {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
const trimmed = modelDraft.trim()
|
const trimmed = modelDraft.trim()
|
||||||
const webhookTrimmed = pipelineWebhookUrlDraft.trim()
|
const webhookTrimmed = pipelineWebhookUrlDraft.trim()
|
||||||
const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim()
|
const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim()
|
||||||
|
const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects)
|
||||||
|
? null
|
||||||
|
: resumeProjectsDraft
|
||||||
const updated = await api.updateSettings({
|
const updated = await api.updateSettings({
|
||||||
model: trimmed.length > 0 ? trimmed : null,
|
model: trimmed.length > 0 ? trimmed : null,
|
||||||
pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null,
|
pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null,
|
||||||
jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null,
|
jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null,
|
||||||
|
resumeProjects: resumeProjectsOverride,
|
||||||
})
|
})
|
||||||
setSettings(updated)
|
setSettings(updated)
|
||||||
setModelDraft(updated.overrideModel ?? "")
|
setModelDraft(updated.overrideModel ?? "")
|
||||||
setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "")
|
setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "")
|
||||||
setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "")
|
setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "")
|
||||||
|
setResumeProjectsDraft(updated.resumeProjects)
|
||||||
toast.success("Settings saved")
|
toast.success("Settings saved")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to save settings"
|
const message = error instanceof Error ? error.message : "Failed to save settings"
|
||||||
@ -107,11 +143,17 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null, jobCompleteWebhookUrl: null })
|
const updated = await api.updateSettings({
|
||||||
|
model: null,
|
||||||
|
pipelineWebhookUrl: null,
|
||||||
|
jobCompleteWebhookUrl: null,
|
||||||
|
resumeProjects: null,
|
||||||
|
})
|
||||||
setSettings(updated)
|
setSettings(updated)
|
||||||
setModelDraft("")
|
setModelDraft("")
|
||||||
setPipelineWebhookUrlDraft("")
|
setPipelineWebhookUrlDraft("")
|
||||||
setJobCompleteWebhookUrlDraft("")
|
setJobCompleteWebhookUrlDraft("")
|
||||||
|
setResumeProjectsDraft(updated.resumeProjects)
|
||||||
toast.success("Reset to default")
|
toast.success("Reset to default")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to reset settings"
|
const message = error instanceof Error ? error.message : "Failed to reset settings"
|
||||||
@ -230,6 +272,122 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Resume Projects</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Max projects included</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={lockedCount}
|
||||||
|
max={maxProjectsTotal}
|
||||||
|
value={resumeProjectsDraft?.maxProjects ?? 0}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!resumeProjectsDraft) return
|
||||||
|
const next = Number(event.target.value)
|
||||||
|
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
||||||
|
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
|
||||||
|
}}
|
||||||
|
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
|
||||||
|
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead className="w-[110px]">Base visible</TableHead>
|
||||||
|
<TableHead className="w-[90px]">Locked</TableHead>
|
||||||
|
<TableHead className="w-[140px]">AI selectable</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{profileProjects.map((project) => {
|
||||||
|
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
|
||||||
|
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
|
||||||
|
const excluded = !locked && !aiSelectable
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={project.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="font-medium">{project.name || project.id}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{[project.description, project.date].filter(Boolean).join(" · ")}
|
||||||
|
{excluded ? " · Excluded" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={locked}
|
||||||
|
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (!resumeProjectsDraft) return
|
||||||
|
const isChecked = checked === true
|
||||||
|
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
|
||||||
|
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||||
|
|
||||||
|
if (isChecked) {
|
||||||
|
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
|
||||||
|
const nextSelectable = selectableIds.filter((id) => id !== project.id)
|
||||||
|
const minCap = lockedIds.length
|
||||||
|
setResumeProjectsDraft({
|
||||||
|
...resumeProjectsDraft,
|
||||||
|
lockedProjectIds: lockedIds,
|
||||||
|
aiSelectableProjectIds: nextSelectable,
|
||||||
|
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
||||||
|
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
||||||
|
setResumeProjectsDraft({
|
||||||
|
...resumeProjectsDraft,
|
||||||
|
lockedProjectIds: nextLocked,
|
||||||
|
aiSelectableProjectIds: selectableIds,
|
||||||
|
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={locked ? true : aiSelectable}
|
||||||
|
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (!resumeProjectsDraft) return
|
||||||
|
const isChecked = checked === true
|
||||||
|
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||||
|
const nextSelectable = isChecked
|
||||||
|
? selectableIds.includes(project.id)
|
||||||
|
? selectableIds
|
||||||
|
: [...selectableIds, project.id]
|
||||||
|
: selectableIds.filter((id) => id !== project.id)
|
||||||
|
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
|
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
|
||||||
{isSaving ? "Saving..." : "Save"}
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
|||||||
@ -10,6 +10,12 @@ import * as settingsRepo from '../repositories/settings.js';
|
|||||||
import { runPipeline, processJob, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
|
import { runPipeline, processJob, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
|
||||||
import { createNotionEntry } from '../services/notion.js';
|
import { createNotionEntry } from '../services/notion.js';
|
||||||
import { clearDatabase } from '../db/clear.js';
|
import { clearDatabase } from '../db/clear.js';
|
||||||
|
import {
|
||||||
|
extractProjectsFromProfile,
|
||||||
|
loadResumeProfile,
|
||||||
|
normalizeResumeProjectsSettings,
|
||||||
|
resolveResumeProjectsSettings,
|
||||||
|
} from '../services/resumeProjects.js';
|
||||||
import type { Job, JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse } from '../../shared/types.js';
|
import type { Job, JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse } from '../../shared/types.js';
|
||||||
|
|
||||||
export const apiRouter = Router();
|
export const apiRouter = Router();
|
||||||
@ -224,6 +230,11 @@ apiRouter.get('/settings', async (_req: Request, res: Response) => {
|
|||||||
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||||
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||||
|
|
||||||
|
const profile = await loadResumeProfile();
|
||||||
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
|
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||||
|
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@ -236,6 +247,7 @@ apiRouter.get('/settings', async (_req: Request, res: Response) => {
|
|||||||
jobCompleteWebhookUrl,
|
jobCompleteWebhookUrl,
|
||||||
defaultJobCompleteWebhookUrl,
|
defaultJobCompleteWebhookUrl,
|
||||||
overrideJobCompleteWebhookUrl,
|
overrideJobCompleteWebhookUrl,
|
||||||
|
...resumeProjectsData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -248,6 +260,11 @@ const updateSettingsSchema = z.object({
|
|||||||
model: z.string().trim().min(1).max(200).nullable().optional(),
|
model: z.string().trim().min(1).max(200).nullable().optional(),
|
||||||
pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
||||||
jobCompleteWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
jobCompleteWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
||||||
|
resumeProjects: z.object({
|
||||||
|
maxProjects: z.number().int().min(0).max(50),
|
||||||
|
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||||
|
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||||
|
}).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -272,6 +289,20 @@ apiRouter.patch('/settings', async (req: Request, res: Response) => {
|
|||||||
await settingsRepo.setSetting('jobCompleteWebhookUrl', webhookUrl);
|
await settingsRepo.setSetting('jobCompleteWebhookUrl', webhookUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('resumeProjects' in input) {
|
||||||
|
const resumeProjects = input.resumeProjects ?? null;
|
||||||
|
|
||||||
|
if (resumeProjects === null) {
|
||||||
|
await settingsRepo.setSetting('resumeProjects', null);
|
||||||
|
} else {
|
||||||
|
const profile = await loadResumeProfile();
|
||||||
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
|
const allowed = new Set(catalog.map((p) => p.id));
|
||||||
|
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||||
|
await settingsRepo.setSetting('resumeProjects', JSON.stringify(normalized));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const overrideModel = await settingsRepo.getSetting('model');
|
const overrideModel = await settingsRepo.getSetting('model');
|
||||||
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
||||||
const model = overrideModel || defaultModel;
|
const model = overrideModel || defaultModel;
|
||||||
@ -284,6 +315,11 @@ apiRouter.patch('/settings', async (req: Request, res: Response) => {
|
|||||||
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||||
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||||
|
|
||||||
|
const profile = await loadResumeProfile();
|
||||||
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
|
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||||
|
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@ -296,6 +332,7 @@ apiRouter.patch('/settings', async (req: Request, res: Response) => {
|
|||||||
jobCompleteWebhookUrl,
|
jobCompleteWebhookUrl,
|
||||||
defaultJobCompleteWebhookUrl,
|
defaultJobCompleteWebhookUrl,
|
||||||
overrideJobCompleteWebhookUrl,
|
overrideJobCompleteWebhookUrl,
|
||||||
|
...resumeProjectsData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -317,6 +317,7 @@ export async function processJob(jobId: string): Promise<{
|
|||||||
const pdfResult = await generatePdf(
|
const pdfResult = await generatePdf(
|
||||||
job.id,
|
job.id,
|
||||||
job.tailoredSummary || '',
|
job.tailoredSummary || '',
|
||||||
|
job.jobDescription || '',
|
||||||
DEFAULT_PROFILE_PATH
|
DEFAULT_PROFILE_PATH
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const { settings } = schema
|
|||||||
export type SettingKey = 'model'
|
export type SettingKey = 'model'
|
||||||
| 'pipelineWebhookUrl'
|
| 'pipelineWebhookUrl'
|
||||||
| 'jobCompleteWebhookUrl'
|
| 'jobCompleteWebhookUrl'
|
||||||
|
| 'resumeProjects'
|
||||||
|
|
||||||
export async function getSetting(key: SettingKey): Promise<string | null> {
|
export async function getSetting(key: SettingKey): Promise<string | null> {
|
||||||
const [row] = await db.select().from(settings).where(eq(settings.key, key))
|
const [row] = await db.select().from(settings).where(eq(settings.key, key))
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import { fileURLToPath } from 'url';
|
|||||||
import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
import { getSetting } from '../repositories/settings.js';
|
||||||
|
import { pickProjectIdsForJob } from './projectSelection.js';
|
||||||
|
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
// Paths - can be overridden via env for Docker
|
// Paths - can be overridden via env for Docker
|
||||||
@ -28,11 +32,13 @@ export interface PdfResult {
|
|||||||
*
|
*
|
||||||
* @param jobId - Unique job identifier (used for filename)
|
* @param jobId - Unique job identifier (used for filename)
|
||||||
* @param tailoredSummary - The AI-generated summary to inject
|
* @param tailoredSummary - The AI-generated summary to inject
|
||||||
|
* @param jobDescription - Job description text for project selection
|
||||||
* @param baseResumePath - Path to the base resume JSON (optional)
|
* @param baseResumePath - Path to the base resume JSON (optional)
|
||||||
*/
|
*/
|
||||||
export async function generatePdf(
|
export async function generatePdf(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
tailoredSummary: string,
|
tailoredSummary: string,
|
||||||
|
jobDescription: string,
|
||||||
baseResumePath?: string
|
baseResumePath?: string
|
||||||
): Promise<PdfResult> {
|
): Promise<PdfResult> {
|
||||||
console.log(`📄 Generating PDF for job ${jobId}...`);
|
console.log(`📄 Generating PDF for job ${jobId}...`);
|
||||||
@ -54,6 +60,39 @@ export async function generatePdf(
|
|||||||
} else if (baseResume.basics?.summary) {
|
} else if (baseResume.basics?.summary) {
|
||||||
baseResume.basics.summary = tailoredSummary;
|
baseResume.basics.summary = tailoredSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Select projects (locked + AI-picked) and set visibility for RXResume
|
||||||
|
try {
|
||||||
|
const { catalog, selectionItems } = extractProjectsFromProfile(baseResume);
|
||||||
|
const overrideResumeProjectsRaw = await getSetting('resumeProjects');
|
||||||
|
const { resumeProjects } = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||||
|
|
||||||
|
const locked = resumeProjects.lockedProjectIds;
|
||||||
|
const desiredCount = Math.max(0, resumeProjects.maxProjects - locked.length);
|
||||||
|
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
|
||||||
|
const eligibleProjects = selectionItems.filter((p) => eligibleSet.has(p.id));
|
||||||
|
|
||||||
|
const picked = await pickProjectIdsForJob({
|
||||||
|
jobDescription,
|
||||||
|
eligibleProjects,
|
||||||
|
desiredCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSet = new Set([...locked, ...picked]);
|
||||||
|
const projectsSection = (baseResume as any)?.sections?.projects;
|
||||||
|
const projectItems = projectsSection?.items;
|
||||||
|
if (Array.isArray(projectItems)) {
|
||||||
|
for (const item of projectItems) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const id = typeof (item as any).id === 'string' ? (item as any).id : '';
|
||||||
|
if (!id) continue;
|
||||||
|
(item as any).visible = selectedSet.has(id);
|
||||||
|
}
|
||||||
|
projectsSection.visible = selectedSet.size > 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: fall back to whatever visibility is in base.json
|
||||||
|
}
|
||||||
|
|
||||||
// Write modified resume to temp file
|
// Write modified resume to temp file
|
||||||
const tempResumePath = join(RESUME_GEN_DIR, `temp_resume_${jobId}.json`);
|
const tempResumePath = join(RESUME_GEN_DIR, `temp_resume_${jobId}.json`);
|
||||||
|
|||||||
168
orchestrator/src/server/services/projectSelection.ts
Normal file
168
orchestrator/src/server/services/projectSelection.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { getSetting } from '../repositories/settings.js';
|
||||||
|
|
||||||
|
import type { ResumeProjectSelectionItem } from './resumeProjects.js';
|
||||||
|
|
||||||
|
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||||
|
|
||||||
|
export async function pickProjectIdsForJob(args: {
|
||||||
|
jobDescription: string;
|
||||||
|
eligibleProjects: ResumeProjectSelectionItem[];
|
||||||
|
desiredCount: number;
|
||||||
|
}): Promise<string[]> {
|
||||||
|
const desiredCount = Math.max(0, Math.floor(args.desiredCount));
|
||||||
|
if (desiredCount === 0) return [];
|
||||||
|
|
||||||
|
const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id));
|
||||||
|
if (eligibleIds.size === 0) return [];
|
||||||
|
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideModel = await getSetting('model');
|
||||||
|
const model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||||
|
|
||||||
|
const prompt = buildProjectSelectionPrompt({
|
||||||
|
jobDescription: args.jobDescription,
|
||||||
|
projects: args.eligibleProjects,
|
||||||
|
desiredCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(OPENROUTER_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': 'http://localhost',
|
||||||
|
'X-Title': 'JobOpsOrchestrator',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenRouter error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const content = data.choices[0]?.message?.content;
|
||||||
|
if (!content) throw new Error('No content in response');
|
||||||
|
|
||||||
|
const parsed = JSON.parse(content) as any;
|
||||||
|
const selectedProjectIds = Array.isArray(parsed?.selectedProjectIds) ? parsed.selectedProjectIds : [];
|
||||||
|
const unique: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const id of selectedProjectIds) {
|
||||||
|
if (typeof id !== 'string') continue;
|
||||||
|
const trimmed = id.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (!eligibleIds.has(trimmed)) continue;
|
||||||
|
if (seen.has(trimmed)) continue;
|
||||||
|
seen.add(trimmed);
|
||||||
|
unique.push(trimmed);
|
||||||
|
if (unique.length >= desiredCount) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unique.length === 0) {
|
||||||
|
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unique;
|
||||||
|
} catch {
|
||||||
|
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectSelectionPrompt(args: {
|
||||||
|
jobDescription: string;
|
||||||
|
projects: ResumeProjectSelectionItem[];
|
||||||
|
desiredCount: number;
|
||||||
|
}): string {
|
||||||
|
const projects = args.projects.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
date: p.date,
|
||||||
|
summary: truncate(p.summaryText, 500),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return `
|
||||||
|
You are selecting which projects to include on a resume for a specific job.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Choose up to ${args.desiredCount} project IDs.
|
||||||
|
- Only choose IDs from the provided list.
|
||||||
|
- Prefer projects that strongly match the job description keywords/tech stack.
|
||||||
|
- Prefer projects that signal impact and real-world engineering.
|
||||||
|
- Do NOT invent projects or skills.
|
||||||
|
|
||||||
|
Job description:
|
||||||
|
${args.jobDescription}
|
||||||
|
|
||||||
|
Candidate projects (pick from these IDs only):
|
||||||
|
${JSON.stringify(projects, null, 2)}
|
||||||
|
|
||||||
|
Respond with JSON only, in this exact shape:
|
||||||
|
{
|
||||||
|
"selectedProjectIds": ["id1", "id2"]
|
||||||
|
}
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackPickProjectIds(
|
||||||
|
jobDescription: string,
|
||||||
|
eligibleProjects: ResumeProjectSelectionItem[],
|
||||||
|
desiredCount: number
|
||||||
|
): string[] {
|
||||||
|
const jd = (jobDescription || '').toLowerCase();
|
||||||
|
|
||||||
|
const signals = [
|
||||||
|
'react',
|
||||||
|
'typescript',
|
||||||
|
'javascript',
|
||||||
|
'node',
|
||||||
|
'next.js',
|
||||||
|
'nextjs',
|
||||||
|
'python',
|
||||||
|
'c++',
|
||||||
|
'c#',
|
||||||
|
'java',
|
||||||
|
'kotlin',
|
||||||
|
'sql',
|
||||||
|
'mongodb',
|
||||||
|
'aws',
|
||||||
|
'docker',
|
||||||
|
'graphql',
|
||||||
|
'php',
|
||||||
|
'unity',
|
||||||
|
'tailwind',
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeSignals = signals.filter((s) => jd.includes(s));
|
||||||
|
|
||||||
|
const scored = eligibleProjects
|
||||||
|
.map((p) => {
|
||||||
|
const text = `${p.name} ${p.description} ${p.summaryText}`.toLowerCase();
|
||||||
|
let score = 0;
|
||||||
|
for (const signal of activeSignals) {
|
||||||
|
if (text.includes(signal)) score += 5;
|
||||||
|
}
|
||||||
|
if (/\b(open source|oss)\b/.test(text)) score += 2;
|
||||||
|
if (/\b(api|backend|frontend|full[- ]?stack)\b/.test(text)) score += 1;
|
||||||
|
return { id: p.id, score };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return scored.slice(0, desiredCount).map((s) => s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(input: string, maxChars: number): string {
|
||||||
|
if (input.length <= maxChars) return input;
|
||||||
|
return `${input.slice(0, maxChars - 1).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
164
orchestrator/src/server/services/resumeProjects.ts
Normal file
164
orchestrator/src/server/services/resumeProjects.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from '../../shared/types.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export const DEFAULT_RESUME_PROFILE_PATH =
|
||||||
|
process.env.RESUME_PROFILE_PATH || join(__dirname, '../../../../resume-generator/base.json');
|
||||||
|
|
||||||
|
type ResumeProjectSelectionItem = ResumeProjectCatalogItem & { summaryText: string };
|
||||||
|
|
||||||
|
export async function loadResumeProfile(profilePath: string = DEFAULT_RESUME_PROFILE_PATH): Promise<unknown> {
|
||||||
|
const content = await readFile(profilePath, 'utf-8');
|
||||||
|
return JSON.parse(content) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractProjectsFromProfile(profile: unknown): {
|
||||||
|
catalog: ResumeProjectCatalogItem[];
|
||||||
|
selectionItems: ResumeProjectSelectionItem[];
|
||||||
|
} {
|
||||||
|
const items = (profile as any)?.sections?.projects?.items;
|
||||||
|
if (!Array.isArray(items)) return { catalog: [], selectionItems: [] };
|
||||||
|
|
||||||
|
const catalog: ResumeProjectCatalogItem[] = [];
|
||||||
|
const selectionItems: ResumeProjectSelectionItem[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
|
||||||
|
const id = typeof (item as any).id === 'string' ? (item as any).id : '';
|
||||||
|
if (!id) continue;
|
||||||
|
|
||||||
|
const name = typeof (item as any).name === 'string' ? (item as any).name : '';
|
||||||
|
const description = typeof (item as any).description === 'string' ? (item as any).description : '';
|
||||||
|
const date = typeof (item as any).date === 'string' ? (item as any).date : '';
|
||||||
|
const isVisibleInBase = Boolean((item as any).visible);
|
||||||
|
const summary = typeof (item as any).summary === 'string' ? (item as any).summary : '';
|
||||||
|
const summaryText = stripHtml(summary);
|
||||||
|
|
||||||
|
const base: ResumeProjectCatalogItem = { id, name, description, date, isVisibleInBase };
|
||||||
|
catalog.push(base);
|
||||||
|
selectionItems.push({ ...base, summaryText });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { catalog, selectionItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultResumeProjectsSettings(
|
||||||
|
catalog: ResumeProjectCatalogItem[]
|
||||||
|
): ResumeProjectsSettings {
|
||||||
|
const lockedProjectIds = catalog.filter((p) => p.isVisibleInBase).map((p) => p.id);
|
||||||
|
const lockedSet = new Set(lockedProjectIds);
|
||||||
|
|
||||||
|
const aiSelectableProjectIds = catalog
|
||||||
|
.map((p) => p.id)
|
||||||
|
.filter((id) => !lockedSet.has(id));
|
||||||
|
|
||||||
|
const total = catalog.length;
|
||||||
|
const preferredMax = Math.max(lockedProjectIds.length, 4);
|
||||||
|
const maxProjects = total === 0 ? 0 : Math.min(total, preferredMax);
|
||||||
|
|
||||||
|
return normalizeResumeProjectsSettings(
|
||||||
|
{ maxProjects, lockedProjectIds, aiSelectableProjectIds },
|
||||||
|
new Set(catalog.map((p) => p.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseResumeProjectsSettings(raw: string | null): ResumeProjectsSettings | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as any;
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null;
|
||||||
|
const maxProjects = parsed.maxProjects;
|
||||||
|
const lockedProjectIds = parsed.lockedProjectIds;
|
||||||
|
const aiSelectableProjectIds = parsed.aiSelectableProjectIds;
|
||||||
|
|
||||||
|
if (typeof maxProjects !== 'number') return null;
|
||||||
|
if (!Array.isArray(lockedProjectIds) || !Array.isArray(aiSelectableProjectIds)) return null;
|
||||||
|
if (!lockedProjectIds.every((v: unknown) => typeof v === 'string')) return null;
|
||||||
|
if (!aiSelectableProjectIds.every((v: unknown) => typeof v === 'string')) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxProjects,
|
||||||
|
lockedProjectIds,
|
||||||
|
aiSelectableProjectIds,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeResumeProjectsSettings(
|
||||||
|
settings: ResumeProjectsSettings,
|
||||||
|
allowedProjectIds?: ReadonlySet<string>
|
||||||
|
): ResumeProjectsSettings {
|
||||||
|
const allowed = allowedProjectIds && allowedProjectIds.size > 0 ? allowedProjectIds : null;
|
||||||
|
|
||||||
|
const lockedProjectIds = uniqueStrings(settings.lockedProjectIds).filter((id) => (allowed ? allowed.has(id) : true));
|
||||||
|
const lockedSet = new Set(lockedProjectIds);
|
||||||
|
|
||||||
|
const aiSelectableProjectIds = uniqueStrings(settings.aiSelectableProjectIds)
|
||||||
|
.filter((id) => (allowed ? allowed.has(id) : true))
|
||||||
|
.filter((id) => !lockedSet.has(id));
|
||||||
|
|
||||||
|
const maxCap = allowed ? allowed.size : Number.POSITIVE_INFINITY;
|
||||||
|
const maxProjectsRaw = Number.isFinite(settings.maxProjects) ? settings.maxProjects : 0;
|
||||||
|
const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw));
|
||||||
|
const minRequired = lockedProjectIds.length;
|
||||||
|
const maxProjects = Math.min(maxCap, Math.max(minRequired, maxProjectsInt));
|
||||||
|
|
||||||
|
return { maxProjects, lockedProjectIds, aiSelectableProjectIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveResumeProjectsSettings(args: {
|
||||||
|
catalog: ResumeProjectCatalogItem[];
|
||||||
|
overrideRaw: string | null;
|
||||||
|
}): {
|
||||||
|
profileProjects: ResumeProjectCatalogItem[];
|
||||||
|
defaultResumeProjects: ResumeProjectsSettings;
|
||||||
|
overrideResumeProjects: ResumeProjectsSettings | null;
|
||||||
|
resumeProjects: ResumeProjectsSettings;
|
||||||
|
} {
|
||||||
|
const profileProjects = args.catalog;
|
||||||
|
const allowed = new Set(profileProjects.map((p) => p.id));
|
||||||
|
const defaultResumeProjects = buildDefaultResumeProjectsSettings(profileProjects);
|
||||||
|
const overrideParsed = parseResumeProjectsSettings(args.overrideRaw);
|
||||||
|
const overrideResumeProjects = overrideParsed
|
||||||
|
? normalizeResumeProjectsSettings(overrideParsed, allowed)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const resumeProjects = overrideResumeProjects
|
||||||
|
? normalizeResumeProjectsSettings(overrideResumeProjects, allowed)
|
||||||
|
: defaultResumeProjects;
|
||||||
|
|
||||||
|
return {
|
||||||
|
profileProjects,
|
||||||
|
defaultResumeProjects,
|
||||||
|
overrideResumeProjects,
|
||||||
|
resumeProjects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripHtml(input: string): string {
|
||||||
|
const withoutTags = input.replace(/<[^>]*>/g, ' ');
|
||||||
|
return withoutTags.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (seen.has(trimmed)) continue;
|
||||||
|
seen.add(trimmed);
|
||||||
|
out.push(trimmed);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ResumeProjectSelectionItem };
|
||||||
|
|
||||||
@ -172,6 +172,20 @@ export interface PipelineStatusResponse {
|
|||||||
nextScheduledRun: string | null;
|
nextScheduledRun: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResumeProjectCatalogItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
date: string;
|
||||||
|
isVisibleInBase: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeProjectsSettings {
|
||||||
|
maxProjects: number;
|
||||||
|
lockedProjectIds: string[];
|
||||||
|
aiSelectableProjectIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
model: string;
|
model: string;
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
@ -182,4 +196,8 @@ export interface AppSettings {
|
|||||||
jobCompleteWebhookUrl: string;
|
jobCompleteWebhookUrl: string;
|
||||||
defaultJobCompleteWebhookUrl: string;
|
defaultJobCompleteWebhookUrl: string;
|
||||||
overrideJobCompleteWebhookUrl: string | null;
|
overrideJobCompleteWebhookUrl: string | null;
|
||||||
|
profileProjects: ResumeProjectCatalogItem[];
|
||||||
|
resumeProjects: ResumeProjectsSettings;
|
||||||
|
defaultResumeProjects: ResumeProjectsSettings;
|
||||||
|
overrideResumeProjects: ResumeProjectsSettings | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user