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:
2026-07-03 11:22:14 +02:00
co-authored by Claude Opus 4.8
parent 5f02413410
commit cbdbbc07e3
6 changed files with 405 additions and 43 deletions
+130 -12
View File
@@ -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');
}
});