Manual generation, LLM notes with company address, and static attachments
- Static extra attachments: upload files (e.g. Zeugnisse) once on the Vorlagen
page; they are attached to every generated application and listed under
"Anlagen" in the cover letter (via multer upload + basis_anhaenge table).
- Import no longer auto-generates: an imported job is saved as a draft
("nicht_gestartet"); generation is triggered manually on the application page.
- Per-application "LLM-Notizen" field: free text (company address, contact
person, extra context) that is fed to the model at generation time. Saving the
notes and (re)generating happens in one action.
- Cover letter recipient block is now a structured empfaenger (firma, address,
city, contact person) the model fills from the job + notes; the salutation
adapts to a named contact. Falls back to the imported company + city.
- Raise default OLLAMA_TIMEOUT_MS to 300s for slower cloud models.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
|
||||
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
|
||||
// (git-ignored) .env file into process.env without overwriting existing vars.
|
||||
@@ -75,6 +76,25 @@ if (!fs.existsSync(anhaengeDir)) {
|
||||
fs.mkdirSync(anhaengeDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Directory for static extra attachments (e.g. Zeugnisse) the user uploads once
|
||||
// and that are sent along with every generated application.
|
||||
const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge');
|
||||
if (!fs.existsSync(basisAnhaengeDir)) {
|
||||
fs.mkdirSync(basisAnhaengeDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Multipart upload for those static attachments
|
||||
const uploadBasisAnhang = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, basisAnhaengeDir),
|
||||
filename: (req, file, cb) => {
|
||||
const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
cb(null, `${Date.now()}_${safe}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
||||
}).single('datei');
|
||||
|
||||
// Database setup
|
||||
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
@@ -147,6 +167,7 @@ async function runGeneration(bewerbungId) {
|
||||
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]);
|
||||
if (!bewerbung) return;
|
||||
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC');
|
||||
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||
|
||||
const documents = await generateApplicationDocuments({
|
||||
@@ -159,15 +180,32 @@ async function runGeneration(bewerbungId) {
|
||||
},
|
||||
basisDokumente,
|
||||
settings,
|
||||
// Names of the static attachments so the cover letter can list them under "Anlagen".
|
||||
zusatzAnlagen: basisAnhaenge.map((a) => a.name || a.dateiname),
|
||||
// Free-text notes (company address, contact person, extra context) for the LLM.
|
||||
llmNotizen: bewerbung.llm_notizen || '',
|
||||
});
|
||||
|
||||
for (const doc of documents) {
|
||||
const stored = `${bewerbungId}_${Date.now()}_${doc.filename}`;
|
||||
fs.writeFileSync(path.join(anhaengeDir, stored), doc.buffer);
|
||||
let seq = 0;
|
||||
const storeAnhang = async (name, filename, mime, buffer) => {
|
||||
const stored = `${bewerbungId}_${Date.now()}_${seq++}_${filename}`;
|
||||
fs.writeFileSync(path.join(anhaengeDir, stored), buffer);
|
||||
await dbRun(
|
||||
'INSERT INTO anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
|
||||
[bewerbungId, doc.name, doc.filename, doc.mime, stored]
|
||||
[bewerbungId, name, filename, mime, stored]
|
||||
);
|
||||
};
|
||||
|
||||
// Generated (AI) documents
|
||||
for (const doc of documents) {
|
||||
await storeAnhang(doc.name, doc.filename, doc.mime, doc.buffer);
|
||||
}
|
||||
|
||||
// Static extra attachments (e.g. Zeugnisse) — copied as-is
|
||||
for (const ba of basisAnhaenge) {
|
||||
const src = path.join(basisAnhaengeDir, ba.pfad);
|
||||
if (!fs.existsSync(src)) continue;
|
||||
await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src));
|
||||
}
|
||||
|
||||
await dbRun(
|
||||
@@ -214,6 +252,7 @@ function initializeDatabase() {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN quelle_url TEXT', () => {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status TEXT', () => {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_fehler TEXT', () => {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN llm_notizen TEXT', () => {
|
||||
|
||||
// Chronological status changes, each with an optional comment
|
||||
db.run(`
|
||||
@@ -241,6 +280,19 @@ function initializeDatabase() {
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Static extra attachments (e.g. Zeugnisse) attached to every application
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS basis_anhaenge (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
dateiname TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
pfad TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Generated attachment files linked to an application
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS anhaenge (
|
||||
@@ -286,6 +338,8 @@ function initializeDatabase() {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -431,7 +485,7 @@ initializeDatabase().then(() => {
|
||||
const result = await dbRun(
|
||||
`INSERT INTO bewerbungen
|
||||
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
|
||||
VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'ausstehend')`,
|
||||
VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||
[datum, firma, stelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
|
||||
);
|
||||
|
||||
@@ -441,14 +495,13 @@ initializeDatabase().then(() => {
|
||||
[result.lastID, datum, 'Entwurf', 'Automatisch über Indeed importiert']
|
||||
);
|
||||
|
||||
// Kick off AI document generation in the background — respond immediately.
|
||||
runGeneration(result.lastID);
|
||||
|
||||
// Note: generation is NOT started automatically — the user reviews the draft,
|
||||
// adds LLM notes if needed, and triggers generation on the application page.
|
||||
res.json({
|
||||
success: true,
|
||||
id: result.lastID,
|
||||
url: `/bewerbung/${result.lastID}`,
|
||||
message: 'Bewerbung als Entwurf angelegt. Die Unterlagen werden nun generiert.',
|
||||
message: 'Bewerbung als Entwurf angelegt. Unterlagen können auf der Bewerbungsseite generiert werden.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error importing job:', error);
|
||||
@@ -692,8 +745,10 @@ initializeDatabase().then(() => {
|
||||
app.get('/vorlagen', async (req, res) => {
|
||||
try {
|
||||
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC');
|
||||
res.render('vorlagen', {
|
||||
basisDokumente,
|
||||
basisAnhaenge,
|
||||
basisTypOptions: BASIS_TYP_OPTIONS,
|
||||
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
|
||||
hideSettings: true,
|
||||
@@ -748,6 +803,63 @@ initializeDatabase().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Static extra attachments (Zeugnisse etc.) -----
|
||||
|
||||
// Upload an attachment
|
||||
app.post('/anlagen', (req, res) => {
|
||||
uploadBasisAnhang(req, res, async (err) => {
|
||||
try {
|
||||
if (err) {
|
||||
console.error('Upload error:', err.message);
|
||||
return res.redirect('/vorlagen');
|
||||
}
|
||||
if (req.file) {
|
||||
const original = req.file.originalname || req.file.filename;
|
||||
const name = (req.body.name && req.body.name.trim())
|
||||
? sanitizeInput(req.body.name.trim())
|
||||
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
|
||||
await dbRun(
|
||||
'INSERT INTO basis_anhaenge (name, dateiname, mime, pfad) VALUES (?, ?, ?, ?)',
|
||||
[name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
|
||||
);
|
||||
}
|
||||
res.redirect('/vorlagen');
|
||||
} catch (error) {
|
||||
console.error('Error saving attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Download a static attachment
|
||||
app.get('/anlagen/:id/download', async (req, res) => {
|
||||
try {
|
||||
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]);
|
||||
if (!a) return res.status(404).send('Anlage nicht gefunden');
|
||||
const filePath = path.join(basisAnhaengeDir, a.pfad);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||||
res.download(filePath, a.dateiname);
|
||||
} catch (error) {
|
||||
console.error('Error downloading attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a static attachment
|
||||
app.post('/anlagen/:id/delete', async (req, res) => {
|
||||
try {
|
||||
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]);
|
||||
if (a) {
|
||||
fs.promises.unlink(path.join(basisAnhaengeDir, a.pfad)).catch(() => {});
|
||||
await dbRun('DELETE FROM basis_anhaenge WHERE id = ?', [req.params.id]);
|
||||
}
|
||||
res.redirect('/vorlagen');
|
||||
} catch (error) {
|
||||
console.error('Error deleting attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Download a generated attachment
|
||||
app.get('/anhaenge/:id/download', async (req, res) => {
|
||||
try {
|
||||
@@ -779,13 +891,19 @@ initializeDatabase().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Re-run the AI generation for an application (removes old generated files first)
|
||||
app.post('/bewerbung/:id/regenerate', async (req, res) => {
|
||||
// Start (or re-run) the AI generation for an application. Saves the LLM notes
|
||||
// first, removes any previously generated attachments, then generates.
|
||||
app.post('/bewerbung/:id/generieren', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]);
|
||||
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
||||
|
||||
// Persist the LLM notes the user provided (used as context during generation)
|
||||
if (typeof req.body.llm_notizen !== 'undefined') {
|
||||
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]);
|
||||
}
|
||||
|
||||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
||||
for (const a of alte) {
|
||||
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
|
||||
@@ -796,7 +914,7 @@ initializeDatabase().then(() => {
|
||||
runGeneration(id);
|
||||
res.redirect('/bewerbung/' + id);
|
||||
} catch (error) {
|
||||
console.error('Error regenerating documents:', error);
|
||||
console.error('Error generating documents:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user