iobroker.autodoc 0.9.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/admin/autodoc.png +0 -0
- package/admin/i18n/de.json +244 -0
- package/admin/i18n/en.json +241 -0
- package/admin/i18n/es.json +229 -0
- package/admin/i18n/fr.json +235 -0
- package/admin/i18n/it.json +229 -0
- package/admin/i18n/nl.json +229 -0
- package/admin/i18n/pl.json +229 -0
- package/admin/i18n/pt.json +229 -0
- package/admin/i18n/ru.json +229 -0
- package/admin/i18n/uk.json +229 -0
- package/admin/i18n/zh-cn.json +229 -0
- package/admin/jsonConfig.json +1490 -0
- package/io-package.json +253 -0
- package/lib/adapter-config.d.ts +19 -0
- package/lib/aiEnhancer.js +2114 -0
- package/lib/autoHostTopologyMermaid.js +195 -0
- package/lib/dependencyAnalyzer.js +83 -0
- package/lib/diagnosisSnapshot.js +32 -0
- package/lib/discovery.js +953 -0
- package/lib/docTemplateConfig.js +422 -0
- package/lib/documentModel.js +640 -0
- package/lib/forumCard.js +70 -0
- package/lib/guestHelpContent.js +93 -0
- package/lib/guestScriptPrivacy.js +14 -0
- package/lib/hostDisplay.js +19 -0
- package/lib/htmlRenderer.js +4108 -0
- package/lib/htmlThemePresets.js +79 -0
- package/lib/htmlToPdf.js +99 -0
- package/lib/i18n.js +1309 -0
- package/lib/markdownRenderer.js +2025 -0
- package/lib/mermaidAutodocPalette.js +165 -0
- package/lib/mermaidServerSvg.js +252 -0
- package/lib/notifier.js +124 -0
- package/lib/quickStartGuide.js +180 -0
- package/lib/roleMapper.js +90 -0
- package/lib/scriptGroups.js +78 -0
- package/lib/versionTracker.js +312 -0
- package/main.js +1368 -0
- package/package.json +88 -0
|
@@ -0,0 +1,2114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutoDoc AI Enhancer
|
|
3
|
+
* Generates narrative documentation text via pluggable AI providers (opt-in).
|
|
4
|
+
* Supported providers: ollama (local/private), mistral (EU/GDPR), groq (US/free), anthropic (paid/premium)
|
|
5
|
+
* When a provider is set, runs two tailored calls (user vs onboarding) for the HTML exports.
|
|
6
|
+
* The documentation profile (admin/user/onboarding) only affects Markdown focus, not whether AI runs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const https = require('node:https');
|
|
10
|
+
const http = require('node:http');
|
|
11
|
+
const { formatOperatingSystemLine } = require('./hostDisplay');
|
|
12
|
+
|
|
13
|
+
/** Output budget for user-profile AI (OpenAI-style max_tokens). */
|
|
14
|
+
const MAX_TOKENS_USER = 900;
|
|
15
|
+
/** Richer onboarding guest text needs a higher ceiling (especially local Ollama). */
|
|
16
|
+
const MAX_TOKENS_ONBOARDING = 2000;
|
|
17
|
+
/** Second pass: normalize German onboarding to consistent "Sie"; user profile to "du". */
|
|
18
|
+
const MAX_TOKENS_ONBOARDING_POLISH = 1400;
|
|
19
|
+
/** German polish pass (onboarding/user): low temperature keeps edits close to the source text. */
|
|
20
|
+
const TEMPERATURE_ONBOARDING_POLISH = 0.2;
|
|
21
|
+
|
|
22
|
+
/** When admin leaves temperature empty, Ollama defaults are often too “creative” for small models — use a conservative default. */
|
|
23
|
+
const OLLAMA_DEFAULT_TEMPERATURE_USER = 0.32;
|
|
24
|
+
const OLLAMA_DEFAULT_TEMPERATURE_ONBOARDING = 0.36;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse optional admin temperature (text or number). Empty / invalid → omit (provider default).
|
|
28
|
+
*
|
|
29
|
+
* @param {unknown} raw Config value
|
|
30
|
+
* @returns {number|undefined} Finite number in [0, 2], or undefined
|
|
31
|
+
*/
|
|
32
|
+
function parseOptionalTemperature(raw) {
|
|
33
|
+
if (raw === undefined || raw === null) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const s = String(raw).trim();
|
|
37
|
+
if (!s) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const n = parseFloat(s.replace(',', '.'));
|
|
41
|
+
if (!Number.isFinite(n)) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return Math.min(2, Math.max(0, n));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Default HTTP timeout for one LLM request (local 8B on CPU often needs several minutes). */
|
|
48
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 300000;
|
|
49
|
+
const MIN_REQUEST_TIMEOUT_MS = 30000;
|
|
50
|
+
const MAX_REQUEST_TIMEOUT_MS = 900000;
|
|
51
|
+
|
|
52
|
+
/** Max scripts to send for source analysis (token/time budget). */
|
|
53
|
+
const MAX_SCRIPT_SOURCE_AI = 32;
|
|
54
|
+
/** Max characters of (redacted) source per script for the LLM. */
|
|
55
|
+
const MAX_SCRIPT_CHARS_FOR_AI = 12000;
|
|
56
|
+
const MIN_MAX_SCRIPT_CHARS_FOR_AI = 2000;
|
|
57
|
+
const ABS_MAX_SCRIPT_CHARS_FOR_AI = 100000;
|
|
58
|
+
/** Completion budget per script explanation. */
|
|
59
|
+
const MAX_TOKENS_SCRIPT_SUMMARY = 450;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {object} [config] Adapter `native` config
|
|
63
|
+
* @returns {number}
|
|
64
|
+
*/
|
|
65
|
+
function parseMaxScriptCharsForAi(config) {
|
|
66
|
+
const raw = config && config.aiMaxScriptCharsForAi;
|
|
67
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
68
|
+
return MAX_SCRIPT_CHARS_FOR_AI;
|
|
69
|
+
}
|
|
70
|
+
const n = Number(raw);
|
|
71
|
+
if (!Number.isFinite(n)) {
|
|
72
|
+
return MAX_SCRIPT_CHARS_FOR_AI;
|
|
73
|
+
}
|
|
74
|
+
return Math.min(ABS_MAX_SCRIPT_CHARS_FOR_AI, Math.max(MIN_MAX_SCRIPT_CHARS_FOR_AI, Math.round(n)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Redact lines that likely contain secrets before sending script source to an LLM.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} source
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
function redactScriptSourceForAi(source) {
|
|
84
|
+
const lines = String(source || '').split(/\r?\n/);
|
|
85
|
+
const sensitive =
|
|
86
|
+
/password|passwd|token|secret|apikey|api[_-]?key|authorization|bearer|credential|private[_-]?key|client[_-]?secret/i;
|
|
87
|
+
return lines.map(line => (sensitive.test(line) ? '[line omitted — possible secret]' : line)).join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} source
|
|
92
|
+
* @param {number} max
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
function truncateScriptSource(source, max) {
|
|
96
|
+
const t = String(source || '');
|
|
97
|
+
if (t.length <= max) {
|
|
98
|
+
return t;
|
|
99
|
+
}
|
|
100
|
+
return `${t.slice(0, max)}\n[… truncated …]`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Optional admin override: seconds per API call (empty → default 300s).
|
|
105
|
+
*
|
|
106
|
+
* @param {object} [config] Adapter config
|
|
107
|
+
* @returns {number} Timeout in milliseconds
|
|
108
|
+
*/
|
|
109
|
+
function parseRequestTimeoutMs(config) {
|
|
110
|
+
if (!config) {
|
|
111
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
112
|
+
}
|
|
113
|
+
const raw = config.aiRequestTimeoutSeconds;
|
|
114
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
115
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
116
|
+
}
|
|
117
|
+
const n = parseInt(String(raw).trim(), 10);
|
|
118
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
119
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
120
|
+
}
|
|
121
|
+
const ms = n * 1000;
|
|
122
|
+
return Math.min(MAX_REQUEST_TIMEOUT_MS, Math.max(MIN_REQUEST_TIMEOUT_MS, ms));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** English labels for device categories — sent to the LLM for factual grounding (matches roleMapper categories). */
|
|
126
|
+
const CAPABILITY_LABEL_EN = {
|
|
127
|
+
light: 'lighting',
|
|
128
|
+
dimmer: 'dimmed lighting',
|
|
129
|
+
blind: 'blinds or shutters',
|
|
130
|
+
thermostat: 'heating or room temperature',
|
|
131
|
+
humidity: 'humidity',
|
|
132
|
+
motion: 'motion detection',
|
|
133
|
+
door: 'doors',
|
|
134
|
+
window: 'windows',
|
|
135
|
+
alarm: 'alarm',
|
|
136
|
+
lock: 'locks',
|
|
137
|
+
switch: 'switches or outlets',
|
|
138
|
+
media: 'media playback',
|
|
139
|
+
camera: 'cameras',
|
|
140
|
+
power: 'power or energy metering',
|
|
141
|
+
other: 'other or unclassified devices',
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Per-room capability summary from real ioBroker roles (reduces invented devices/scenes).
|
|
146
|
+
*
|
|
147
|
+
* @param {object} docModel Document model
|
|
148
|
+
* @param {number} maxRooms Max rooms to include
|
|
149
|
+
* @returns {string} Block for the LLM prompt
|
|
150
|
+
*/
|
|
151
|
+
function buildRoomCapabilityGrounding(docModel, maxRooms = 22) {
|
|
152
|
+
const rooms = docModel.rooms?.rooms;
|
|
153
|
+
if (!rooms || rooms.length === 0) {
|
|
154
|
+
return [
|
|
155
|
+
'--- Grounding: rooms ---',
|
|
156
|
+
'No rooms are defined in this export. Do not invent named rooms; keep wording generic (e.g. "here in the home").',
|
|
157
|
+
].join('\n');
|
|
158
|
+
}
|
|
159
|
+
const lines = [
|
|
160
|
+
'--- Grounding: real rooms + device categories inferred from ioBroker roles (only these may inspire concrete wording) ---',
|
|
161
|
+
];
|
|
162
|
+
for (const r of rooms.slice(0, maxRooms)) {
|
|
163
|
+
const devs = r.devices || [];
|
|
164
|
+
const cats = new Set();
|
|
165
|
+
for (const d of devs) {
|
|
166
|
+
const key = d.category || 'other';
|
|
167
|
+
cats.add(CAPABILITY_LABEL_EN[key] || CAPABILITY_LABEL_EN.other);
|
|
168
|
+
}
|
|
169
|
+
const capStr =
|
|
170
|
+
devs.length === 0
|
|
171
|
+
? 'no devices linked in this export'
|
|
172
|
+
: [...cats].sort().join('; ') || CAPABILITY_LABEL_EN.other;
|
|
173
|
+
lines.push(`• Room "${r.name}": ${devs.length} linked device(s); observed categories: ${capStr}`);
|
|
174
|
+
}
|
|
175
|
+
lines.push(
|
|
176
|
+
'End grounding. Do not claim specific sensors, appliances, pressures, furniture, shopping, backpacks, or subsystems unless they fit these categories and room names. If information is thin, stay short and generic.',
|
|
177
|
+
);
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Same factual content as the exported “Quick start” / “Quick overview” blocks (Phase 5.x.2), as English-only
|
|
183
|
+
* grounding for the **user**-profile LLM — reduces contradictions with discovery; not used for guest onboarding
|
|
184
|
+
* (guest prompts already forbid IT-style inventory there).
|
|
185
|
+
*
|
|
186
|
+
* @param {object} docModel Document model
|
|
187
|
+
* @returns {string} Block or empty string
|
|
188
|
+
*/
|
|
189
|
+
function buildQuickStartGroundingForAi(docModel) {
|
|
190
|
+
const qs = docModel && docModel.quickStart;
|
|
191
|
+
if (!qs || !qs.hasContent) {
|
|
192
|
+
return '';
|
|
193
|
+
}
|
|
194
|
+
const lines = [
|
|
195
|
+
'--- Grounding: AutoDoc quick overview (same facts as User/Onboarding “Quick start” / “Quick overview” in the export, English labels) ---',
|
|
196
|
+
];
|
|
197
|
+
for (const it of qs.systemItems || []) {
|
|
198
|
+
if (it.kind === 'roomCount') {
|
|
199
|
+
lines.push(`• Rooms with at least one device (count from discovery): ${it.n}`);
|
|
200
|
+
} else if (it.kind === 'function') {
|
|
201
|
+
lines.push(`• Function theme “${String(it.name)}”: about ${it.memberCount} device(s)`);
|
|
202
|
+
} else if (it.kind === 'script') {
|
|
203
|
+
const d = String(it.desc || '')
|
|
204
|
+
.trim()
|
|
205
|
+
.slice(0, 220);
|
|
206
|
+
lines.push(`• Script with description “${String(it.name)}”: ${d}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const rg of qs.roomGuides || []) {
|
|
210
|
+
const samples = (rg.highlights || [])
|
|
211
|
+
.map(h => {
|
|
212
|
+
const v =
|
|
213
|
+
h.valueText != null && h.valueText !== ''
|
|
214
|
+
? `${h.deviceName} (${h.valueText})`
|
|
215
|
+
: String(h.deviceName || '');
|
|
216
|
+
return v;
|
|
217
|
+
})
|
|
218
|
+
.join('; ');
|
|
219
|
+
lines.push(
|
|
220
|
+
`• Room “${rg.name}”: ${rg.deviceCount} device(s) in export; sample devices/values: ${samples || '—'}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
lines.push(
|
|
224
|
+
'End quick-overview grounding. Paraphrase in the output language; do not invent devices, rooms, or automations beyond these lines and the room-capability block.',
|
|
225
|
+
);
|
|
226
|
+
return lines.join('\n');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {{ narrative?: string, recommendations?: string } | null | undefined} block Parsed AI sections
|
|
231
|
+
* @returns {boolean} True if missing or both strings are blank
|
|
232
|
+
*/
|
|
233
|
+
function isAiBlockEmpty(block) {
|
|
234
|
+
if (block == null) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
const n = String(block.narrative || '').trim();
|
|
238
|
+
const r = String(block.recommendations || '').trim();
|
|
239
|
+
return !n && !r;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const DEFAULT_MODELS = {
|
|
243
|
+
ollama: 'llama3.2',
|
|
244
|
+
mistral: 'mistral-small-latest',
|
|
245
|
+
groq: 'llama-3.3-70b-versatile',
|
|
246
|
+
anthropic: 'claude-haiku-4-5-20251001',
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const LANG_NAMES = {
|
|
250
|
+
de: 'German',
|
|
251
|
+
fr: 'French',
|
|
252
|
+
en: 'English',
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** When onboarding AI fails or returns admin-style text, use this instead of copying the user-profile block. */
|
|
256
|
+
const NEUTRAL_ONBOARDING_GUEST = {
|
|
257
|
+
de: {
|
|
258
|
+
narrative:
|
|
259
|
+
'Willkommen in diesem Zuhause. Licht, Temperatur oder Beschattung können hier automatisch mitlaufen — das ist gewollt und von den Bewohnern eingerichtet. Die einzelnen Räume und weitere Orientierung finden Sie in den Abschnitten weiter unten auf dieser Seite.',
|
|
260
|
+
recommendations:
|
|
261
|
+
'- Bei Fragen oder Anliegen wenden Sie sich bitte an die Bewohner.\n- Bitte ändern Sie keine Einstellungen ohne deren Zustimmung.\n- Weitere Informationen zu den Bereichen dieses Zuhauses stehen in der Dokumentation unterhalb dieses Kastens.',
|
|
262
|
+
},
|
|
263
|
+
en: {
|
|
264
|
+
narrative:
|
|
265
|
+
'Welcome. Lighting, temperature, or shades may run automatically here — that is intentional. You will find rooms and more orientation in the sections further down on this page.',
|
|
266
|
+
recommendations:
|
|
267
|
+
'- For questions, please ask the people who live here.\n- Please do not change settings without their consent.\n- More information appears in the documentation below this box.',
|
|
268
|
+
},
|
|
269
|
+
fr: {
|
|
270
|
+
narrative:
|
|
271
|
+
"Bienvenue. L'éclairage, la température ou les stores peuvent fonctionner automatiquement — c'est voulu. Vous trouverez les pièces et des repères dans les sections plus bas sur cette page.",
|
|
272
|
+
recommendations:
|
|
273
|
+
"- Pour toute question, adressez-vous aux personnes qui habitent ici.\n- Merci de ne pas modifier les réglages sans leur accord.\n- Plus d'informations figurent dans la documentation sous cet encadré.",
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Detect Bewohner/Admin-style KI text wrongly shown to guests.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} narrative
|
|
281
|
+
* @param {string} recommendations
|
|
282
|
+
* @returns {boolean}
|
|
283
|
+
*/
|
|
284
|
+
function onboardingTextLooksLikeTechnicalDump(narrative, recommendations) {
|
|
285
|
+
const b = `${narrative || ''}\n${recommendations || ''}`;
|
|
286
|
+
if (/\bio\s*broker|\bioBroker\b|iroBroker|IroBroker|\biro\s+broker\b/i.test(b)) {
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
if (
|
|
290
|
+
/js-controller|Skriptausführung|Skripte?\s+fehlen|Adapter(?:suche)?|Gerätetreiber|Instanz(?:en)?|BackItUp|Repository|Wartungs.*score/i.test(
|
|
291
|
+
b,
|
|
292
|
+
)
|
|
293
|
+
) {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
if (/\b\d+\s+(?:von|\/)\s*\d+\s+Adapter/i.test(b)) {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
if (/Host\s*[„"'']?[0-9a-f]{8,}/i.test(b)) {
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
if (/Installation.*Version\s+\d+\.\d+/i.test(b)) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* @param {string} langCode
|
|
310
|
+
* @returns {{ narrative: string, recommendations: string }}
|
|
311
|
+
*/
|
|
312
|
+
function getNeutralOnboardingGuestBlock(langCode) {
|
|
313
|
+
const lc = (langCode || 'en').toLowerCase();
|
|
314
|
+
if (lc === 'de') {
|
|
315
|
+
return { ...NEUTRAL_ONBOARDING_GUEST.de };
|
|
316
|
+
}
|
|
317
|
+
if (lc === 'fr') {
|
|
318
|
+
return { ...NEUTRAL_ONBOARDING_GUEST.fr };
|
|
319
|
+
}
|
|
320
|
+
return { ...NEUTRAL_ONBOARDING_GUEST.en };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Short factual DE text when the model output is still unusable after polish (no extra LLM call).
|
|
325
|
+
*
|
|
326
|
+
* @param {object} docModel
|
|
327
|
+
* @returns {{ narrative: string, recommendations: string }}
|
|
328
|
+
*/
|
|
329
|
+
function buildGermanUserFallbackBlock(docModel) {
|
|
330
|
+
const sys = docModel.system;
|
|
331
|
+
const stats = sys.statistics || {};
|
|
332
|
+
const en = stats.enabledInstanceCount ?? 0;
|
|
333
|
+
const dis = stats.disabledInstanceCount ?? 0;
|
|
334
|
+
const rooms = docModel.rooms?.totalRooms ?? 0;
|
|
335
|
+
const name = (sys.projectName || 'ioBroker').trim() || 'ioBroker';
|
|
336
|
+
const parts = [`Kurzüberblick zur Installation „${name}“.`, `${en} Adapter-Instanzen sind aktiv.`];
|
|
337
|
+
if (dis > 0) {
|
|
338
|
+
parts.push(`${dis} Instanzen sind derzeit deaktiviert — das kann Absicht sein.`);
|
|
339
|
+
}
|
|
340
|
+
if (rooms > 0) {
|
|
341
|
+
parts.push(`${rooms} Räume oder Bereiche sind in dieser Dokumentation genannt.`);
|
|
342
|
+
}
|
|
343
|
+
parts.push('Einzelheiten zu Adaptern, Geräten, Skripten und Werten stehen in den folgenden Kapiteln dieser Seite.');
|
|
344
|
+
const narrative = parts.join(' ');
|
|
345
|
+
const recommendations = [
|
|
346
|
+
'- Listen und Tabellen zu Adaptern, Räumen und Skripten findest du weiter unten auf dieser Seite.',
|
|
347
|
+
'- Bevor du deaktivierte Instanzen wieder einschaltest, kurz prüfen, ob die Absicht war.',
|
|
348
|
+
'- Wenn etwas an den Automationen hakt, klärt es am besten im Haushalt miteinander.',
|
|
349
|
+
].join('\n');
|
|
350
|
+
return { narrative, recommendations };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Resident (user-profile) DE text still contains known small-model garbage after polish.
|
|
355
|
+
*
|
|
356
|
+
* @param {string} narrative
|
|
357
|
+
* @param {string} recommendations
|
|
358
|
+
* @returns {boolean}
|
|
359
|
+
*/
|
|
360
|
+
function germanUserAiStillUnacceptable(narrative, recommendations) {
|
|
361
|
+
const b = `${narrative || ''}\n${recommendations || ''}`;
|
|
362
|
+
if (!b.trim()) {
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (/iroBroker|IroBroker|\biro\s+broker\b|iRover|\bIr[oó]ver\b|Adapatoren?|Adapator|\bGerätee\b/i.test(b)) {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
if (/Haushälter|Haushalter\b|Putzfrau|Entspannung.*Spezialist|Spezialist.*Entspannung/i.test(b)) {
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
if (/besprecht\s+Sie|fähartig|einfächert|überwünscht|Lichtanregungen/i.test(b)) {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Guest onboarding DE text still unsafe or absurd after polish — fall back to neutral guest block.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} narrative
|
|
381
|
+
* @param {string} recommendations
|
|
382
|
+
* @returns {boolean}
|
|
383
|
+
*/
|
|
384
|
+
function germanOnboardingGuestStillUnacceptable(narrative, recommendations) {
|
|
385
|
+
const b = `${narrative || ''}\n${recommendations || ''}`;
|
|
386
|
+
if (!b.trim()) {
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
if (/Haushälter|Haushalter\b|Putzfrau|Reinigungsfirma/i.test(b)) {
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
if (/iroBroker|IroBroker|\biro\s+broker\b|iRover|\bIr[oó]ver\b/i.test(b)) {
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
if (/Entspannung.*Spezialist|Spezialist.*Entspannung/i.test(b)) {
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Deterministic touch-ups for common small-model glitches in German guest copy (onboarding).
|
|
403
|
+
* Runs before/after the optional Sie/Grammar LLM pass so broken phrases still get fixed.
|
|
404
|
+
*
|
|
405
|
+
* @param {string} narrative
|
|
406
|
+
* @param {string} recommendations
|
|
407
|
+
* @returns {{ narrative: string, recommendations: string }}
|
|
408
|
+
*/
|
|
409
|
+
function applyGermanGuestDeterministicFixes(narrative, recommendations) {
|
|
410
|
+
const fix = t => {
|
|
411
|
+
let s = String(t || '');
|
|
412
|
+
// Dangling "vor" after "Bewohner" (truncation / calque) — e.g. "sich an die Bewohner vor."
|
|
413
|
+
s = s.replace(/\b(an die Bewohner|an die hier wohnenden|die Bewohner)\s+vor([.!?,…\s]|$)/gi, '$1$2');
|
|
414
|
+
// "sich wenden … zu den Abschnitten" is not idiomatic; models often mangle the end of the sentence
|
|
415
|
+
s = s.replace(
|
|
416
|
+
/Bitte wenden Sie sich (?:bitte )?zu den folgenden Abschnitten/gi,
|
|
417
|
+
'In den folgenden Abschnitten finden Sie',
|
|
418
|
+
);
|
|
419
|
+
s = s.replace(
|
|
420
|
+
/\bWenden Sie sich (?:bitte )?zu den folgenden Abschnitten/gi,
|
|
421
|
+
'In den folgenden Abschnitten finden Sie',
|
|
422
|
+
);
|
|
423
|
+
s = s.replace(
|
|
424
|
+
/\bwenden Sie sich (?:bitte )?zu den folgenden Abschnitten/gi,
|
|
425
|
+
'in den folgenden Abschnitten finden Sie',
|
|
426
|
+
);
|
|
427
|
+
s = s.replace(
|
|
428
|
+
/\b([Bb]itte )in den folgenden Abschnitten finden Sie\b/g,
|
|
429
|
+
'In den folgenden Abschnitten finden Sie',
|
|
430
|
+
);
|
|
431
|
+
// "lassen … unterstützt" — rephrase; not valid (wrong participle / calque of "get support")
|
|
432
|
+
s = s.replace(
|
|
433
|
+
/und lassen Sie sich von (?:ihnen|den Bewohnern) unterstützt([.!?,…\s]|$)/gi,
|
|
434
|
+
'und wenden Sie sich gern an die Bewohner, wenn Sie etwas brauchen$1',
|
|
435
|
+
);
|
|
436
|
+
s = s.replace(
|
|
437
|
+
/lassen Sie sich von (?:ihnen|den Bewohnern) unterstützt([.!?,…\s]|$)/gi,
|
|
438
|
+
'wenden Sie sich gern an die Bewohner, wenn Sie Hilfe brauchen$1',
|
|
439
|
+
);
|
|
440
|
+
s = s.replace(
|
|
441
|
+
/lassen Sie sich von ihnen unterstützt([.!?,…\s]|$)/gi,
|
|
442
|
+
'lassen Sie sich gern helfen, wenn Sie Fragen haben$1',
|
|
443
|
+
);
|
|
444
|
+
return s;
|
|
445
|
+
};
|
|
446
|
+
return {
|
|
447
|
+
narrative: fix(narrative),
|
|
448
|
+
recommendations: fix(recommendations),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** OpenAI/Ollama system role: keeps small models closer to guest-safe output */
|
|
453
|
+
const ONBOARDING_SYSTEM_MESSAGE =
|
|
454
|
+
'You write only short welcome text for house guests. Never name home-automation software brands, protocols (MQTT, CoAP, …), APIs, adapters, instances, IP addresses, or programming tools (JavaScript, Blockly, …). Never output Denglish. Never mention documentation setup scores, maintenance scores, disabled components, backup tools, or “how many adapters”. Never type the substring "Broker" or the letters io+Broker as a product name. Do not invent specific appliance behaviours (AC warming food, exact sunrise blind rules) — stay vague unless facts explicitly say so. Rewrite any technical input into plain household language. If unsure, omit the detail.';
|
|
455
|
+
|
|
456
|
+
/** German system text so local models think in idiomatic German, not English-then-translate */
|
|
457
|
+
const ONBOARDING_SYSTEM_MESSAGE_DE =
|
|
458
|
+
'Du schreibst kurze Willkommenstexte für Gäste: natürliches Höflichkeits-Deutsch mit "Sie" in jedem Satz und in jeder Aufzählung — niemals "du", niemals du-Imperative wie "Lies", "Überprüfe", "Aktualisiere", "Halte", "Sei". Das Wort "ioBroker" und alles mit "Broker" als Produktname ist verboten (sage stattdessen "zu Hause", "die Steuerung hier", "dieses Zuhause"). Keine Adapter, Instanzen, Skript-Manager, maintenance-score, Wartungszahlen, Doku-Setup-Score, BackItUp, Telegram-Adapter, Webinterface als Fachbegriff. Keine erfundenen Abläufe (Klima wärmt Essen, exakte Sonnenauf-/Untergangs-Szenen), wenn nicht klar aus den Fakten — lieber vorsichtig allgemein. Kein Denglish: kein Englisch mitten im Satz (kein "our", "home page(s)", keine halben englischen Sätze); kein "Schedule", "Score". Räume grammatikalisch richtig: **das** Arbeitszimmer, **das** Schlafzimmer — niemals "der Arbeitszimmer". **Das WC** (neutrum), nie "der WC". Keine Personifizierung: Räume "leben" oder "arbeiten" nicht — stattdessen "In diesem Zuhause gibt es …", "Zu den Bereichen gehören …". **Wenn Sie noch Fragen haben**, nie "wenn Sie sich noch Fragen macht". Licht und Temperatur **an die Bedürfnisse anpassen** oder **laufen oft mit** — nie holprige Kalque wie "auf Ihrem Bedürfnis ausgehen". Gäste fragen, ob sie **einen Raum nutzen** oder **betreten dürfen** — nicht "im Raum verbrauchen". **Die Bewohner** und **ihre Grenzen** — nicht "Respekt seiner Grenzen" bei mehreren Bewohnern. Keine Hotel- oder Behördenklischees: **Beschilderungen** im Privathaushalt nur wenn wirklich plausibel, sonst weglassen. Kein widersinniger Rat wie "einen ruhigen Ort stören, wenn Sie müde sind". Aufzählungen mit "- " am Zeilenanfang, keine leeren oder doppelten Sternchen-Zeilen. Keine kaputten Wörter oder Pseudo-Komposita (z. B. "Schiebemögliche"); Fensterbeschattung: **Jalousien**, **Rollläden**, **Beschattung** — niemals "Blinde"/"Blinden" für Rollläden (falsche Bedeutung). Kein ganzer Text in markdown ** eingepackt; keine Meta-Schlussfloskel ("Ich hoffe, diese Vorschläge …"). Kein Software-Marketing ("freue mich Sie kennenzulernen", "komfortables Erlebnis") — Ton wie eine einladende Wohnung, nicht wie eine App. Keine Gästeansprache mit IT-Jargon (Algorithmen, sensibel im Tech-Sinn). Bewohner **bieten** Hilfe an / stehen für Fragen zur Verfügung — nicht formulieren, als würden die Bewohner "Hilfe anfragen". Keine sinnfremden Themen (Autobahn, Fernsehen, Nachrichten, Streaming) — nur Zuhause, Gäste, Bewohner, Komfort, Sicherheit. Schreibe nur über Räume und Gerätetypen, die im Prompt-Block "Grounding" vorkommen — keine Märchen, kein Verkauf, kein Gepäck, keine erfundenen Zimmernamen. Niemals "Vorgesetzte" oder "Ihre Vorgesetzten" im Privathaushalt — Ansprechpartner sind die Bewohner oder die hier Wohnenden. Nie hängendes **vor** nach **Bewohner** (kein „… an die Bewohner vor.“). **Wenden** mit **an** + Person — nicht „sich wenden … zu den folgenden Abschnitten“; stattdessen **in den folgenden Abschnitten finden Sie …** / **weiter unten auf dieser Seite**. **Lassen Sie sich … unterstützt** ist ungrammatisch — **sich gern an die Bewohner wenden** oder **sich helfen lassen**. Wiederhole nicht im Fließtext und in jedem Stichpunkt fast dieselbe Aussage.';
|
|
459
|
+
|
|
460
|
+
const USER_SYSTEM_MESSAGE_DE =
|
|
461
|
+
'Du schreibst praxisnahe Smart-Home-Hinweise im **User-/Familien-Profil** an dieselben Menschen, die **hier wohnen** und ioBroker **alltäglich nutzen**. Anrede: durchgängig **du** / **dein** / **dir** (wie unter sich / im Haushalt) — **kein** formelles **Sie** und kein Fach-Helpdesk-Sprech. Produktname immer exakt **ioBroker** (kleines io, großes B) — niemals **iroBroker** / **IroBroker** / **iro broker**. **Adapter** / **Adapter-Instanzen** — niemals **Adapatoren**. Keine erfundenen Geräte, Marken, Putz- oder Dienstleister-Fiktion. Keine Denglish-Wortformen (**Disablete** usw.); keine erfundenen Komposita. Ruhig und sachlich, nicht alarmistisch. Nicht befehlen, heute nacht **alle** deaktivierten Adapter anzuwerfen — viele bewusst aus. **Keine** Meta-Rolle „**wende dich an mich** / **ich helfe Ihnen** / **melden Sie sich**“ — die Box ist Doku, kein Personen-Chat. Interne Host-Hex-IDs nicht wörtlich. Nur Fakten/Setup aus dem Grounding — keine Märchenszenen.';
|
|
462
|
+
|
|
463
|
+
/** Editor pass after onboarding generation (German only). */
|
|
464
|
+
const GERMAN_ONBOARDING_POLISH_SYSTEM =
|
|
465
|
+
'Du bist deutscher Lektor für Gästetexte. Du änderst nur Anrede, Grammatik und holprige Formulierungen. Du erfindest keine neuen Fakten und keine neuen Produktnamen. Themenfremde oder unsinnige Stichpunkte (z. B. Autobahnverkehr, Fernsehen, Nachrichten) streichen oder durch einen sachlichen Gast-Hinweis zu diesem Zuhause ersetzen — nicht stehen lassen. **Haushälter**, **Haushalter**, Putzfrau, Reinigungsfirma oder anderes erfundenes Dienstpersonal **entfernen** — Gäste wenden sich an **Bewohner** / **die hier wohnen**. Holpriges "sich auf unsere Seiten beschäftigen" → natürlich ("in den Abschnitten unten nachlesen" o. Ä.). Englisch in deutscher Ausgabe entfernen (z. B. "our home pages" → "in den Abschnitten auf dieser Seite" / "bei den Räumen unten"). "Der Arbeitszimmer" → "Das Arbeitszimmer"; **der WC** → **das WC**. Sätze, in denen **Räume leben oder arbeiten**, in natürliches Deutsch umschreiben (Zuhause hat Räume / es gibt …). **sich noch Fragen macht** → **noch Fragen haben** o. Ä. Kalque **Bedürfnis ausgehen** / **auf Ihrem Bedürfnis** → **an Ihre Bedürfnisse anpassen** oder **passt sich oft an**. **im Raum verbrauchen** → **einen Raum nutzen** / **betreten**. **Respekt seiner Grenzen** bei Bewohnern → **ihre Grenzen** / **die Grenzen der Bewohner**. Unplausible **Beschilderungen** im Privathaushalt streichen. Widersinn wie **Ort stören, wenn müde** bereinigen. Meta-Doppelungen (**Dazu steht gerne Hilfe** nach langem Einleitungssatz) kürzen. Leere oder kaputte Aufzählungszeilen (nur Sternchen, oder Backslash-Stern vor dem Text) entfernen oder mit dem Nachbarsatz zusammenführen. Kaputte Wörter wie "Schiebemögliche" durch sinnvolles Deutsch ersetzen. Fenster-"blinds": **Blinde/Blinden** bei Rollläden → **Jalousien** oder **Rollläden**. Unsinn wie "Wetter einzuklagen" streichen. Meta-Schlussfloskeln und äußere markdown-**-Rahmen um ganze Absätze entfernen. **Vorgesetzte** im Privathaushalt → **Bewohner** / **die hier wohnen**. Bewohner **bieten** Hilfe **an** — nicht "Hilfe anzufragen" für die Bewohner. Holpriges "Anstoß haben" → "ein Anliegen haben". "Wohnern" → "Bewohnern". Kaputte Phrasen: **an die Bewohner vor** / **die Bewohner vor.** → vollständiger Satz (z. B. **wenden Sie sich an die Bewohner** oder **sprechen Sie vorher mit den Bewohnern**). **wenden … zu den folgenden Abschnitten** → **in den folgenden Abschnitten finden Sie …** / **sehen Sie weiter unten nach**. **lassen Sie sich … unterstützt** → **sich gern an die Bewohner wenden** / **sich helfen lassen**. Ausgabe immer exakt mit NARRATIVE: und RECOMMENDATIONS: wie vorgegeben.';
|
|
466
|
+
|
|
467
|
+
/** Second pass for German **user** (resident) profile — same tooling as onboarding polish, different brief. */
|
|
468
|
+
const GERMAN_USER_POLISH_SYSTEM =
|
|
469
|
+
'Du bist deutscher Lektor für **Familien-/Bewohner-Texte** in einem ioBroker-**User**-Export. Die Lesenden wohnen im selben Haushalt — Anrede **du** / **dein** / **dir** (oder **euch** an mehrere). Entferne formelles **Sie**, **Ihr**, **Ihnen** und Helpdesk-Serien (**Überprüfen Sie**); ersetze durch natürliches **du** (z. B. **Prüf mal …**, **Kurz checken, ob …**). Keine Plural-Imperative an eine Person (**Überprüft**). Keine Doku, die sich als Person ausgibt: **Ich helfe Ihnen** / **wenden Sie sich an mich** / **melden Sie sich** — ersetze durch sachlich neutrale oder **du**-Hinweise. Produktname **ioBroker** — nie **iroBroker** / **IroBroker** / **iro broker**. **Adapter**, nicht **Adapatoren** / **Adapator**. Erfundenes Personal/Marken streichen. **Dein** / **euer** Smart Home — nicht „Ihren Smart Home“. **NARRATIVE**/**RECOMMENDATIONS** nur als Überschriften, nicht im Fließtext. Keine englischen Formatwörter. Keine erfundenen Adapter. Ausgabe exakt mit NARRATIVE: und RECOMMENDATIONS: wie vorgegeben.';
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Prompt block: native-sounding German (reduces calques, Behördendeutsch, broken Sie-forms).
|
|
473
|
+
*
|
|
474
|
+
* @returns {string}
|
|
475
|
+
*/
|
|
476
|
+
function buildGermanNaturalLanguageBlock() {
|
|
477
|
+
return `
|
|
478
|
+
German style and wording (mandatory — sounds human, not translated):
|
|
479
|
+
- Write as a native German speaker would: idiomatic word order and connectors (deshalb, übrigens, gerne, natürlich), not English sentences with German words.
|
|
480
|
+
- Polite "Sie" with correct verb agreement everywhere (e.g. "Überprüfen Sie …", "Bitte wenden Sie sich …"). Never use plural imperative forms that address "ihr" ("Überprüft …", "Besprecht …") when using "Sie".
|
|
481
|
+
- Prefer short clear main clauses; split long ideas into two sentences. Avoid heavy noun stacks (Nominalstil) and bureaucratic filler ("Im Rahmen von …", "Es erfolgt eine …").
|
|
482
|
+
- Do not start with empty marketing openers ("Um ein perfektes Zuhause zu schaffen …") or abstract meta-sentences about "the first look into devices".
|
|
483
|
+
- No Denglish: no English fragments ("of", "the", mixed clauses). Established loanwords used in German (App, WLAN, Smart Home) are fine if the sentence is otherwise fully German.
|
|
484
|
+
- Bullets: full sentences or "Bitte …" + "Sie"; each line must read naturally if spoken aloud.
|
|
485
|
+
- Avoid odd literal metaphors (brain of the home, device writers, operational newspaper) — use normal household German.
|
|
486
|
+
- Grammar: "dieses Dokument lesen/durchlesen", not "diesem Dokument …"; vehicles drive or park "ein", not "einheben".
|
|
487
|
+
`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* German style for **user** (family) profile: informal **du**, not support-desk "Sie".
|
|
492
|
+
*
|
|
493
|
+
* @returns {string}
|
|
494
|
+
*/
|
|
495
|
+
function buildGermanResidentDuLanguageBlock() {
|
|
496
|
+
return `
|
|
497
|
+
German **family / resident** profile (mandatory — like talking to your household, not a customer):
|
|
498
|
+
- Anrede: durchgängig **du** / **dein** / **deine** / **dich** / **dir**; optional **euer** / **euch** wenn klar an mehrere Bewohner. **Kein** formelles **Sie** / **Ihr** / **Ihnen** (klingt im Familien-Profil unnatürlich).
|
|
499
|
+
- Sätze wie üblich in einem normalen deutschen Zuhause — nicht hölzern, nicht Kundendienst, nicht "Sehr geehrte".
|
|
500
|
+
- Wenn Ratschläge: **du**-Imperative oder "du kannst", "klingt sinnvoll, wenn du …" — keine starren 5×-Wiederholung "Überprüfen Sie …" / "Prüf …" in jedem Stichpunkt. Keine formellen Plural-Imperative an eine Person (**Überprüft** statt "ihr" an alle).
|
|
501
|
+
- Keine Person, der man sich "anvertrauen" muss: keine Formulierungen "wende dich an mich", "ich stehe **Ihnen** zur Verfügung", "melde dich bei mir" (die Doku-Box ist kein Mitarbeiter). Stattdessen sachlich: "Hinweis:", "Lohnt sich mal:", "Wenn was klemmt, …" oder an Mitbewohner denken, nicht an einen Dienstleister.
|
|
502
|
+
- Klar, kurze Hauptsätze; kein Denglish; gängige Lehnwörter (App, WLAN) ok.
|
|
503
|
+
- Grammatik: "dieses Kapitel lesen" — nicht wörtlich unidiomatische Kalquen. **das** Arbeitszimmer / **das** WC (nie **der WC**).`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Extra rules for **user** (resident) profile in German — Denglish, brands, tone.
|
|
508
|
+
*
|
|
509
|
+
* @returns {string}
|
|
510
|
+
*/
|
|
511
|
+
function buildGermanUserResidentBlock() {
|
|
512
|
+
return `
|
|
513
|
+
German — residents (not guests):
|
|
514
|
+
- No Denglish word shapes: write **deaktiviert** / **ausgeschaltete Komponenten**, not pseudo-words like "Disablete"; use **Skriptausführungen** or plain **laufende Automatisierungen**, not mangled compounds ("Skriptausführe"); do not turn adjectives into odd nouns (no **Hilfswillig** as a noun).
|
|
515
|
+
- For **brand/product names** (Shelly, BackItUp, Telegram, …): mention them only if they appear as adapter titles in the system data above; otherwise use generic terms (smart switch, backup, message the household, notification).
|
|
516
|
+
- **Maintenance / disabled instances:** factual and calm — no alarmist or surreal phrasing; short sentences.
|
|
517
|
+
- **Bullets:** do not repeat the same idea five times (documentation / support / updates) — merge into one clear tip where possible.
|
|
518
|
+
- **Grammar:** neuter nouns use "jedes" (jedes Skript, jedes Gerät), not "jeden"; prefer real words like **Funktionsfehler** — do not invent broken compounds ("Functionierungsfehler", etc.).
|
|
519
|
+
- **Addressing residents:** this profile uses **du** (family / same household) — not formal **Sie**. Never sound like a call centre; never write as a chatbot "wende dich an mich" for the help box.
|
|
520
|
+
- **Host / IDs:** do not paste cryptic host or container ids (hex strings) into the text — paraphrase ("dein ioBroker-Server", project title).
|
|
521
|
+
- **Disabled adapters:** never order residents to "switch every disabled adapter on tonight" — some may be intentional; suggest reviewing whether deactivation is deliberate.
|
|
522
|
+
- **Wording:** no broken compounds ("Sicherheitssicherheit", "telegramms"); use normal German ("Sicherheitsüberprüfung", "Telegram-Bot" if the data mentions Telegram).
|
|
523
|
+
- **No meta bullets:** do not waste a recommendation line explaining that "ioBroker is only a program" or defining the product — residents already use it; every bullet must be a concrete habit or check tied to the data above.
|
|
524
|
+
- **Grammar:** "in **diesem** Projekt" / "im Kontext **dieses Projekts**" — not "dieses Projekt" as object of "im Kontext" without genitive.
|
|
525
|
+
`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Shared German heuristics for the optional second LLM polish pass.
|
|
530
|
+
*
|
|
531
|
+
* @param {string} narrative
|
|
532
|
+
* @param {string} recommendations
|
|
533
|
+
* @param {'onboarding'|'user'} profile **onboarding** (guest) wants **Sie**; **user** (residents) wants **du**
|
|
534
|
+
* @returns {boolean}
|
|
535
|
+
*/
|
|
536
|
+
function germanDeTextNeedsPolishForAudience(narrative, recommendations, profile) {
|
|
537
|
+
const blob = `${narrative || ''}\n${recommendations || ''}`;
|
|
538
|
+
if (!blob.trim()) {
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
if (profile === 'onboarding' && /\b(du|dir|dich|euch)\b/i.test(blob)) {
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
// Family / resident text: formal address is wrong — should be du
|
|
545
|
+
if (profile === 'user' && /(?:\bSie\b|\bIhnen\b|\bIhre\b|\bIhrem\b|\bIhren\b|\bIhres\b|\bIhr\b)/.test(blob)) {
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
if (/Frag\s+uns|frag\s+uns/i.test(blob)) {
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
if (/\bLies\b/.test(blob)) {
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
if (
|
|
555
|
+
/^\s*[-*•]?\s*(Lies|Überprüfe|Frag|Stelle\s+sicher,\s*dass\s+du|Halte|Aktiviere|Besuche|Nimm|Nutze)\b/im.test(
|
|
556
|
+
recommendations || '',
|
|
557
|
+
)
|
|
558
|
+
) {
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
// Denglish, wrong articles, broken compounds, or wrong "Hilfe anfragen" for hosts — run lektor pass
|
|
562
|
+
if (/\b(our|home\s+pages?)\b/i.test(blob)) {
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
if (/\bDer\s+Arbeitszimmer\b|\bSchiebemögliche\b|\bAlgorithmen\b/i.test(blob)) {
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
if (/Bewohner.*Hilfe\s+anzufragen|Hilfe\s+anzufragen.*Bewohner/i.test(blob)) {
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
if (/\bbereit,\s*Hilfe\s+anzufragen\b/i.test(blob)) {
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
if (/\bBlinde(?:n)?\s+oder\s+Rolllad/i.test(blob) || /einzuklagen/i.test(blob)) {
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
if (/\bIch hoffe,?\s+diese\s+Vorschläge\b/i.test(blob)) {
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
if (/\bVorgesetzt/i.test(blob)) {
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
// Unrealistic / broken German common in small-model onboarding
|
|
584
|
+
if (/Räume.*\b(leben|arbeiten)\b|\b(leben|arbeiten)\b.*\bRäume\b/i.test(blob)) {
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
if (/\bder\s+WC\b/i.test(blob)) {
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
if (/\bFragen\s+macht\b/i.test(blob) || /\bsich\s+noch\s+Fragen\s+macht/i.test(blob)) {
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
if (/Bedürfnis\s+auszugehen|auf\s+Ihrem\s+Bedürfnis|auf\s+Ihre\s+Bedürfnis\b/i.test(blob)) {
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
if (/\bverbrauchen\b.*\bRaum|\bRaum\b.*\bverbrauchen\b/i.test(blob)) {
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
if (/Respekt\s+seiner\s+Grenzen|Bewohner.*seiner\s+Grenzen/i.test(blob)) {
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
if (/\bBeschilderung/i.test(blob)) {
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
if (/\bstören\s+Sie\s+(ihn|den\s+Ort)/i.test(blob) && /\bmüde\b/i.test(blob)) {
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
if (/Dazu\s+steht\s+gerne\s+Hilfe|steht\s+gerne\s+einige\s+Tipps/i.test(blob)) {
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
if (/Haushälter|Haushalter\b|unseren\s+Seiten\s+beschäftigen|beschäftigen.*\bSeiten\b/i.test(blob)) {
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
if (
|
|
615
|
+
/(?:^|\n)\s*[-*•]?\s*\\\*\s*\S/m.test(recommendations || '') ||
|
|
616
|
+
/(?:^|\n)\s*\*\s*\\\*/m.test(recommendations || '')
|
|
617
|
+
) {
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
// Broken collocations common in small-model German guest text
|
|
621
|
+
if (/\b(an die Bewohner|die Bewohner)\s+vor[.!?,…\s]/i.test(blob)) {
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
if (/wenden Sie sich[^.!?\n]{0,120}zu den folgenden Abschnitten/i.test(blob)) {
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
if (/lassen Sie sich[^.!?\n]{0,60}unterstützt/i.test(blob)) {
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Heuristic: German onboarding (guest) text likely needs Sie-polish.
|
|
635
|
+
*
|
|
636
|
+
* @param {string} narrative
|
|
637
|
+
* @param {string} recommendations
|
|
638
|
+
* @returns {boolean}
|
|
639
|
+
*/
|
|
640
|
+
function germanOnboardingNeedsSiePolish(narrative, recommendations) {
|
|
641
|
+
return germanDeTextNeedsPolishForAudience(narrative, recommendations, 'onboarding');
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Heuristic: German **resident** (user-profile) text needs the same de-noise as onboarding
|
|
646
|
+
* (minus guest-only triggers) **plus** du-normalisation when formal pronouns appear.
|
|
647
|
+
*
|
|
648
|
+
* @param {string} narrative
|
|
649
|
+
* @param {string} recommendations
|
|
650
|
+
* @returns {boolean}
|
|
651
|
+
*/
|
|
652
|
+
function germanUserProfileNeedsPolish(narrative, recommendations) {
|
|
653
|
+
if (germanDeTextNeedsPolishForAudience(narrative, recommendations, 'user')) {
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
656
|
+
const n = narrative || '';
|
|
657
|
+
const r = recommendations || '';
|
|
658
|
+
const blob = `${n}\n${r}`;
|
|
659
|
+
if (/\bNARRATIVE\b/i.test(n) || /\bRECOMMENDATIONS\b/i.test(n)) {
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
if (/(?:^|\n)\s*[-*•]?\s*Überprüft\s+/m.test(r)) {
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
if (/(?:^|\n)\s*[-*•]?\s*Organisier\w*\s+/im.test(r)) {
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
if (/\borganisierst\b/i.test(blob)) {
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
if (/\bInternet\s+verlassen\b/i.test(blob)) {
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
if (/Smart[- ]?Flasche|Wäschemöck|Licht\s+einführen|vollen\s+tank\s+stand/i.test(blob)) {
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
if (/\bauslese\s*([.!?]|$)/im.test(blob)) {
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
if (/\bIhren\s+Smart\s+Home\b/i.test(n)) {
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
if (
|
|
684
|
+
/iroBroker|IroBroker|iRover|\bIr[oó]ver\b|Adapat|Gerätee\b|Haushälter|Haushalter\b|besprecht\s+Sie|fähartig|einfächert|überwünscht|Lichtanregungen/i.test(
|
|
685
|
+
blob,
|
|
686
|
+
)
|
|
687
|
+
) {
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* User prompt for the German Sie-consistency polish pass (onboarding only).
|
|
695
|
+
*
|
|
696
|
+
* @param {{ narrative: string, recommendations: string }} block
|
|
697
|
+
* @returns {string}
|
|
698
|
+
*/
|
|
699
|
+
function buildGermanOnboardingPolishUserPrompt(block) {
|
|
700
|
+
const n = (block.narrative || '').trim();
|
|
701
|
+
const r = (block.recommendations || '').trim();
|
|
702
|
+
return `Unten steht ein Gäste-Text (Willkommen + Tipps). Aufgabe:
|
|
703
|
+
|
|
704
|
+
1) Durchgängig höfliches **Sie** mit passenden Verbformen. Entferne **du**, **dir**, **dich**, **euch** und alle du-Imperative; ersetze durch natürliche Sie-Formulierungen (z. B. "Bitte lesen Sie …", "Wenden Sie sich …").
|
|
705
|
+
2) Grammatik: z. B. **technischen Support** (Dativ), **das Licht**, keine holprigen Konstruktionen wie "Verständnis im Hinterkopf haben".
|
|
706
|
+
3) Keine leeren Meta-Phrasen: nicht "besuchen Sie uns regelmäßig" oder "nehmen Sie sich unsere Zeit" — Ansprechpartner sind die **Bewohner** ("wenden Sie sich an die Bewohner", "fragen Sie vorher die Bewohner").
|
|
707
|
+
4) Englisch entfernen; falsche Artikel bei Zimmernamen korrigieren (**das** Arbeitszimmer); "Hilfe anzufragen" nur für Gäste, die Hilfe brauchen — Bewohner **bieten** Hilfe **an**.
|
|
708
|
+
5) Keine neuen Fakten, keine neuen Markennamen; Text nicht künstlich verlängern.
|
|
709
|
+
6) Privates Zuhause: **Vorgesetzte** / **Ihre Vorgesetzten** / **von Ihren Vorgesetzten** durch **Bewohner** / **die hier wohnen** / **den Haushalt** ersetzen — nie Büro-Sprache für Smart Home.
|
|
710
|
+
7) Realität: keine lebenden/arbeitenden Räume; **das WC** statt **der WC**; **wenn Sie noch Fragen haben** statt „sich Fragen macht“; Licht/Temperatur natürlich formulieren; Raumnutzung nicht „verbrauchen“; **ihre Grenzen** bei Bewohnern; unplausible **Beschilderung** streichen; widersinnige Ratschläge (Ort „stören“ wenn müde) entfernen.
|
|
711
|
+
8) Aufzählungen: sinnvolle Zeilen mit „- “; leere Sternchen-Zeilen entfernen; falsches Backslash-Sternchen nach Bindestrich/Asterisk am Zeilenanfang bereinigen.
|
|
712
|
+
9) Kein hängendes **vor** nach **Bewohner**; kein **wenden … zu den folgenden Abschnitten** — stattdessen **in den folgenden Abschnitten finden Sie …**; **lassen … unterstützt** vermeiden.
|
|
713
|
+
10) Nicht dieselbe Kernaussage im Fließtext und in fast jedem Stichpunkt wiederholen (variierende Wortwahl reicht nicht).
|
|
714
|
+
|
|
715
|
+
Ausgabe **exakt** in diesem Format (gleiche Überschriften):
|
|
716
|
+
|
|
717
|
+
NARRATIVE:
|
|
718
|
+
<text>
|
|
719
|
+
|
|
720
|
+
RECOMMENDATIONS:
|
|
721
|
+
<text>
|
|
722
|
+
(each recommendation line may start with "- ")
|
|
723
|
+
|
|
724
|
+
Eingabe:
|
|
725
|
+
|
|
726
|
+
NARRATIVE:
|
|
727
|
+
${n}
|
|
728
|
+
|
|
729
|
+
RECOMMENDATIONS:
|
|
730
|
+
${r}`;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* User prompt for the German lektor pass (resident / user profile only).
|
|
735
|
+
*
|
|
736
|
+
* @param {{ narrative: string, recommendations: string }} block
|
|
737
|
+
* @returns {string}
|
|
738
|
+
*/
|
|
739
|
+
function buildGermanUserPolishUserPrompt(block) {
|
|
740
|
+
const n = (block.narrative || '').trim();
|
|
741
|
+
const r = (block.recommendations || '').trim();
|
|
742
|
+
return `Unten steht ein Bewohner-Text (Überblick + konkrete Hinweise) für **dieselben Leute, die im Haus wohnen**. Aufgabe:
|
|
743
|
+
|
|
744
|
+
1) Durchgängig **du**-Anrede (Familien-/Haushalts-Profil). Entferne formelles **Sie** / **Ihr** / **Ihnen**; keine Plural-Imperative (**Überprüft**, **Organisiert** an eine Person) — ersetze durch **du**-Formulierungen (z. B. **Prüf …**, **Hol dir den Überblick …**).
|
|
745
|
+
2) Grammatik: **dein** / **euer** Smart Home — nicht „Ihren Smart Home“; **auslesen**, nicht **auslese** am Satzende.
|
|
746
|
+
3) Offensichtlichen Unsinn streichen. Kein Chatbot-Flair: **Ich helfe Ihnen** / **wenden Sie sich an mich** ersetzen durch neutrale/kurze **du**-Tipps.
|
|
747
|
+
4) **NARRATIVE** und **RECOMMENDATIONS** nur als Überschriften, nicht im Fließtext.
|
|
748
|
+
5) Keine neuen Marken oder Adapter erfinden; Text nicht künstlich strecken.
|
|
749
|
+
|
|
750
|
+
Ausgabe **exakt** in diesem Format:
|
|
751
|
+
|
|
752
|
+
NARRATIVE:
|
|
753
|
+
<text>
|
|
754
|
+
|
|
755
|
+
RECOMMENDATIONS:
|
|
756
|
+
<text>
|
|
757
|
+
(jede Empfehlungszeile darf mit "- " beginnen)
|
|
758
|
+
|
|
759
|
+
Eingabe:
|
|
760
|
+
|
|
761
|
+
NARRATIVE:
|
|
762
|
+
${n}
|
|
763
|
+
|
|
764
|
+
RECOMMENDATIONS:
|
|
765
|
+
${r}`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* @param {number} code HTTP status
|
|
770
|
+
* @param {string} detail Error detail text
|
|
771
|
+
* @returns {Error} Rejection error with `statusCode` for retry logic
|
|
772
|
+
*/
|
|
773
|
+
function apiHttpError(code, detail) {
|
|
774
|
+
return Object.assign(new Error(`API error ${code}: ${detail}`), { statusCode: code });
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Generic HTTP/HTTPS POST helper.
|
|
779
|
+
*
|
|
780
|
+
* @param {object} opts Request options: hostname, port, path, secure, headers
|
|
781
|
+
* @param {object} body JSON request body
|
|
782
|
+
* @param {number} [timeoutMs] Socket timeout (default DEFAULT_REQUEST_TIMEOUT_MS)
|
|
783
|
+
* @returns {Promise<object>} Parsed JSON response
|
|
784
|
+
*/
|
|
785
|
+
function postJson(opts, body, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
786
|
+
return new Promise((resolve, reject) => {
|
|
787
|
+
const payload = JSON.stringify(body);
|
|
788
|
+
const reqOpts = {
|
|
789
|
+
hostname: opts.hostname,
|
|
790
|
+
port: opts.port,
|
|
791
|
+
path: opts.path,
|
|
792
|
+
method: 'POST',
|
|
793
|
+
headers: {
|
|
794
|
+
'Content-Type': 'application/json',
|
|
795
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
796
|
+
...opts.headers,
|
|
797
|
+
},
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
const transport = opts.secure ? https : http;
|
|
801
|
+
const req = transport.request(reqOpts, res => {
|
|
802
|
+
let data = '';
|
|
803
|
+
res.on('data', chunk => {
|
|
804
|
+
data += chunk;
|
|
805
|
+
});
|
|
806
|
+
res.on('end', () => {
|
|
807
|
+
const code = res.statusCode ?? 0;
|
|
808
|
+
let parsed = null;
|
|
809
|
+
if (data) {
|
|
810
|
+
try {
|
|
811
|
+
parsed = JSON.parse(data);
|
|
812
|
+
} catch {
|
|
813
|
+
parsed = null;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (code >= 200 && code < 300) {
|
|
817
|
+
if (parsed == null) {
|
|
818
|
+
reject(new Error('Failed to parse API response: invalid JSON'));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
resolve(parsed);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const detail =
|
|
825
|
+
(parsed && typeof parsed === 'object' && parsed.error?.message) ||
|
|
826
|
+
(data ? String(data).slice(0, 500) : '(empty body)');
|
|
827
|
+
reject(apiHttpError(code, detail));
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
req.on('error', reject);
|
|
832
|
+
req.setTimeout(timeoutMs, () => {
|
|
833
|
+
const err = Object.assign(new Error(`API request timed out after ${Math.round(timeoutMs / 1000)}s`), {
|
|
834
|
+
code: 'ETIMEDOUT',
|
|
835
|
+
});
|
|
836
|
+
req.destroy(err);
|
|
837
|
+
});
|
|
838
|
+
req.write(payload);
|
|
839
|
+
req.end();
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** HTTP statuses and socket errors worth retrying (Ollama often returns 500 under VRAM/load). */
|
|
844
|
+
const TRANSIENT_LLM_HTTP = new Set([429, 500, 502, 503]);
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* @param {Error & { statusCode?: number; code?: string }} err Request failure
|
|
848
|
+
* @returns {boolean} Whether to retry the call
|
|
849
|
+
*/
|
|
850
|
+
function isTransientLlmFailure(err) {
|
|
851
|
+
const c = err && err.statusCode;
|
|
852
|
+
if (typeof c === 'number' && TRANSIENT_LLM_HTTP.has(c)) {
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
if (err && err.code === 'ECONNRESET') {
|
|
856
|
+
return true;
|
|
857
|
+
}
|
|
858
|
+
if (err && err.code === 'ETIMEDOUT') {
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
const msg = err && err.message ? String(err.message) : '';
|
|
862
|
+
if (msg.includes('API request timed out')) {
|
|
863
|
+
return true;
|
|
864
|
+
}
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* POST JSON with a few retries on transient failures (local Ollama 500s, timeouts, 429).
|
|
870
|
+
*
|
|
871
|
+
* @param {object} opts Same as postJson
|
|
872
|
+
* @param {object} body JSON body
|
|
873
|
+
* @param {number} timeoutMs Per-attempt timeout
|
|
874
|
+
* @param {{ warn?: (msg: string) => void }} [log] Optional logger (adapter.log)
|
|
875
|
+
* @returns {Promise<object>} Parsed JSON
|
|
876
|
+
*/
|
|
877
|
+
async function postJsonTransientRetries(opts, body, timeoutMs, log) {
|
|
878
|
+
const maxAttempts = 4;
|
|
879
|
+
const baseDelayMs = 8000;
|
|
880
|
+
let lastErr;
|
|
881
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
882
|
+
try {
|
|
883
|
+
return await postJson(opts, body, timeoutMs);
|
|
884
|
+
} catch (err) {
|
|
885
|
+
lastErr = err;
|
|
886
|
+
const retry = attempt < maxAttempts && isTransientLlmFailure(err);
|
|
887
|
+
if (!retry) {
|
|
888
|
+
throw err;
|
|
889
|
+
}
|
|
890
|
+
const delayMs = Math.min(90000, baseDelayMs * 2 ** (attempt - 1));
|
|
891
|
+
const snippet = (err.message || String(err)).slice(0, 180).replace(/\s+/g, ' ');
|
|
892
|
+
if (log && typeof log.warn === 'function') {
|
|
893
|
+
log.warn(
|
|
894
|
+
`LLM HTTP attempt ${attempt}/${maxAttempts} failed (${snippet}) — retrying in ${Math.round(delayMs / 1000)}s…`,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
throw lastErr;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Call the Anthropic Messages API.
|
|
905
|
+
*
|
|
906
|
+
* @param {string} apiKey Anthropic API key
|
|
907
|
+
* @param {string} model Model ID
|
|
908
|
+
* @param {string} prompt User prompt
|
|
909
|
+
* @param {number} maxTokens Max response tokens
|
|
910
|
+
* @param {string|undefined} systemPrompt System message; omit or empty if none
|
|
911
|
+
* @param {number|undefined} temperature 0–1 (clamped); `undefined` for API default
|
|
912
|
+
* @param {number} timeoutMs HTTP timeout (ms)
|
|
913
|
+
* @returns {Promise<string>} Response text
|
|
914
|
+
*/
|
|
915
|
+
async function callAnthropic(apiKey, model, prompt, maxTokens, systemPrompt, temperature, timeoutMs) {
|
|
916
|
+
const body = {
|
|
917
|
+
model,
|
|
918
|
+
max_tokens: maxTokens,
|
|
919
|
+
messages: [{ role: 'user', content: prompt }],
|
|
920
|
+
};
|
|
921
|
+
if (systemPrompt && systemPrompt.trim()) {
|
|
922
|
+
body.system = systemPrompt.trim();
|
|
923
|
+
}
|
|
924
|
+
if (temperature !== undefined && Number.isFinite(temperature)) {
|
|
925
|
+
body.temperature = Math.min(1, Math.max(0, temperature));
|
|
926
|
+
}
|
|
927
|
+
const response = await postJson(
|
|
928
|
+
{
|
|
929
|
+
hostname: 'api.anthropic.com',
|
|
930
|
+
path: '/v1/messages',
|
|
931
|
+
secure: true,
|
|
932
|
+
headers: {
|
|
933
|
+
'x-api-key': apiKey,
|
|
934
|
+
'anthropic-version': '2023-06-01',
|
|
935
|
+
},
|
|
936
|
+
},
|
|
937
|
+
body,
|
|
938
|
+
timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
939
|
+
);
|
|
940
|
+
return response.content?.[0]?.text || '';
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Call an OpenAI-compatible API (Groq or Ollama).
|
|
945
|
+
*
|
|
946
|
+
* @param {string} baseUrl Full base URL, e.g. https://api.groq.com or http://localhost:11434
|
|
947
|
+
* @param {string} apiKey API key (empty string for Ollama)
|
|
948
|
+
* @param {string} model Model ID
|
|
949
|
+
* @param {string} prompt User prompt
|
|
950
|
+
* @param {number} maxTokens Max response tokens
|
|
951
|
+
* @param {string|undefined} systemPrompt System message; omit or empty if none
|
|
952
|
+
* @param {number|undefined} temperature 0–2 (clamped); `undefined` for API default
|
|
953
|
+
* @param {number} timeoutMs HTTP timeout (ms)
|
|
954
|
+
* @param {{ warn?: (msg: string) => void }} [log] For retry warnings (pass adapter.log)
|
|
955
|
+
* @returns {Promise<string>} Response text
|
|
956
|
+
*/
|
|
957
|
+
async function callOpenAiCompatible(
|
|
958
|
+
baseUrl,
|
|
959
|
+
apiKey,
|
|
960
|
+
model,
|
|
961
|
+
prompt,
|
|
962
|
+
maxTokens,
|
|
963
|
+
systemPrompt,
|
|
964
|
+
temperature,
|
|
965
|
+
timeoutMs,
|
|
966
|
+
log,
|
|
967
|
+
) {
|
|
968
|
+
const url = new URL('/v1/chat/completions', baseUrl);
|
|
969
|
+
const headers = {};
|
|
970
|
+
if (apiKey) {
|
|
971
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const messages = [];
|
|
975
|
+
if (systemPrompt && systemPrompt.trim()) {
|
|
976
|
+
messages.push({ role: 'system', content: systemPrompt.trim() });
|
|
977
|
+
}
|
|
978
|
+
messages.push({ role: 'user', content: prompt });
|
|
979
|
+
|
|
980
|
+
const chatBody = { model, max_tokens: maxTokens, messages };
|
|
981
|
+
if (temperature !== undefined && Number.isFinite(temperature)) {
|
|
982
|
+
chatBody.temperature = Math.min(2, Math.max(0, temperature));
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const t = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
986
|
+
const response = await postJsonTransientRetries(
|
|
987
|
+
{
|
|
988
|
+
hostname: url.hostname,
|
|
989
|
+
port: url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80,
|
|
990
|
+
path: url.pathname,
|
|
991
|
+
secure: url.protocol === 'https:',
|
|
992
|
+
headers,
|
|
993
|
+
},
|
|
994
|
+
chatBody,
|
|
995
|
+
t,
|
|
996
|
+
log,
|
|
997
|
+
);
|
|
998
|
+
return response.choices?.[0]?.message?.content || '';
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Build a compact system summary string from the document model for the prompt.
|
|
1003
|
+
*
|
|
1004
|
+
* @param {object} docModel Document model
|
|
1005
|
+
* @returns {string} Compact summary for the prompt
|
|
1006
|
+
*/
|
|
1007
|
+
function buildSystemSummary(docModel) {
|
|
1008
|
+
const sys = docModel.system;
|
|
1009
|
+
const adapters = docModel.adapters;
|
|
1010
|
+
const rooms = docModel.rooms;
|
|
1011
|
+
const scripts = docModel.scripts;
|
|
1012
|
+
const maintenance = docModel.maintenance;
|
|
1013
|
+
|
|
1014
|
+
const adapterList = adapters.adapters
|
|
1015
|
+
.slice(0, 20)
|
|
1016
|
+
.map(a => `${a.title || a.name}${a.desc ? ` (${a.desc})` : ''}`)
|
|
1017
|
+
.join(', ');
|
|
1018
|
+
|
|
1019
|
+
const roomList = rooms.rooms
|
|
1020
|
+
.slice(0, 15)
|
|
1021
|
+
.map(r => r.name)
|
|
1022
|
+
.join(', ');
|
|
1023
|
+
|
|
1024
|
+
const issueLines = [];
|
|
1025
|
+
|
|
1026
|
+
const documentedScripts = (scripts.scripts || [])
|
|
1027
|
+
.filter(s => s.enabled && s.desc && String(s.desc).trim())
|
|
1028
|
+
.slice(0, 25);
|
|
1029
|
+
const scriptContextLines = documentedScripts.map(s => {
|
|
1030
|
+
const d = String(s.desc).trim().replace(/\s+/g, ' ');
|
|
1031
|
+
const short = d.length > 200 ? `${d.slice(0, 200)}…` : d;
|
|
1032
|
+
return `- ${s.name}: ${short}`;
|
|
1033
|
+
});
|
|
1034
|
+
const scriptContextBlock =
|
|
1035
|
+
scriptContextLines.length > 0
|
|
1036
|
+
? `\nActive scripts with optional common.desc (ioBroker: group-purpose text — use only if relevant, not a full script spec):\n${scriptContextLines.join('\n')}`
|
|
1037
|
+
: '';
|
|
1038
|
+
|
|
1039
|
+
const hostName = sys.primaryHost.name || '';
|
|
1040
|
+
const hostLooksLikeInternalId = /^[0-9a-f]{8,}$/i.test(hostName);
|
|
1041
|
+
const hostHint = hostLooksLikeInternalId
|
|
1042
|
+
? '\nWriter hint: the host "name" above is an internal/container id — do not quote it in the narrative; refer to "your ioBroker server" or use the project name instead.'
|
|
1043
|
+
: '';
|
|
1044
|
+
|
|
1045
|
+
const scheduleModeLines = [];
|
|
1046
|
+
for (const a of adapters.adapters) {
|
|
1047
|
+
for (const inst of a.instances) {
|
|
1048
|
+
if (inst.enabled && inst.mode === 'schedule') {
|
|
1049
|
+
const suffix =
|
|
1050
|
+
inst.scheduleCron && String(inst.scheduleCron).trim() ? ` cron=${inst.scheduleCron}` : '';
|
|
1051
|
+
scheduleModeLines.push(`${a.name}.${String(inst.id).split('.').pop()}${suffix}`);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const scheduleDesignN = docModel.scheduleObjects && docModel.scheduleObjects.length;
|
|
1056
|
+
const automationExtras = [];
|
|
1057
|
+
if (scheduleModeLines.length > 0) {
|
|
1058
|
+
automationExtras.push(
|
|
1059
|
+
`Adapter instances in ioBroker "schedule" run mode (periodic adapter process, not script.js): ${scheduleModeLines.join('; ')}`,
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
if (scheduleDesignN) {
|
|
1063
|
+
automationExtras.push(
|
|
1064
|
+
`Schedule-type objects from object view (type schedule): ${scheduleDesignN} — separate from JS scripts.`,
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const osShort = formatOperatingSystemLine(sys.primaryHost) || '—';
|
|
1069
|
+
const body = [
|
|
1070
|
+
`Project: ${sys.projectName}`,
|
|
1071
|
+
`Host: ${hostName} (OS: ${osShort}; runtime: ${sys.primaryHost.platform}; js-controller ${sys.primaryHost.version})`,
|
|
1072
|
+
`Adapters: ${adapters.totalAdapters} types, ${sys.statistics.enabledInstanceCount} enabled / ${sys.statistics.disabledInstanceCount} disabled instances`,
|
|
1073
|
+
`Top adapters: ${adapterList || 'none'}`,
|
|
1074
|
+
`Rooms: ${rooms.totalRooms} (${roomList || 'none'})`,
|
|
1075
|
+
`Functions: ${rooms.totalFunctions}`,
|
|
1076
|
+
`Scripts: ${scripts.totalScripts} total, ${scripts.enabledScripts} active`,
|
|
1077
|
+
`Documentation setup score: ${maintenance.score}/100`,
|
|
1078
|
+
issueLines.length > 0
|
|
1079
|
+
? `Issues: ${issueLines.join('; ')}`
|
|
1080
|
+
: 'Checklist: no warnings (disabled instances are inventory only).',
|
|
1081
|
+
].join('\n');
|
|
1082
|
+
|
|
1083
|
+
const automationBlock = automationExtras.length > 0 ? `\n${automationExtras.join('\n')}` : '';
|
|
1084
|
+
return `${body}${automationBlock}${scriptContextBlock}${hostHint}`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Minimal facts for onboarding — no adapter lists, no ioBroker string (reduces model copy-paste).
|
|
1089
|
+
*
|
|
1090
|
+
* @param {object} docModel Document model
|
|
1091
|
+
* @returns {string}
|
|
1092
|
+
*/
|
|
1093
|
+
function buildOnboardingSystemSummary(docModel) {
|
|
1094
|
+
const sys = docModel.system;
|
|
1095
|
+
const rooms = docModel.rooms;
|
|
1096
|
+
const roomList = (rooms.rooms || [])
|
|
1097
|
+
.slice(0, 22)
|
|
1098
|
+
.map(r => r.name)
|
|
1099
|
+
.join(', ');
|
|
1100
|
+
return [
|
|
1101
|
+
`Home title for greetings: ${sys.projectName}`,
|
|
1102
|
+
`${rooms.totalRooms} named areas — use these names when helpful: ${roomList || 'none'}`,
|
|
1103
|
+
`Some routines may run on schedules or sensors; in guest text describe only in gentle, general terms (e.g. lights or shutters may adjust). Do not invent detailed stories (meals, sunrise times, AC) unless the room/theme facts clearly imply them.`,
|
|
1104
|
+
`Internal: do not mention counts of scripts, adapters, disabled instances, backup systems, or any numeric "health" or documentation setup score in the guest output — those facts are not in this block on purpose.`,
|
|
1105
|
+
].join('\n');
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Extra structured facts for onboarding prompts (rooms + function themes).
|
|
1110
|
+
*
|
|
1111
|
+
* @param {object} docModel Document model
|
|
1112
|
+
* @returns {string} Block to append to the prompt (English labels OK; model writes in langName)
|
|
1113
|
+
*/
|
|
1114
|
+
function buildOnboardingDetailBlock(docModel) {
|
|
1115
|
+
const rooms = docModel.rooms;
|
|
1116
|
+
if (!rooms) {
|
|
1117
|
+
return '';
|
|
1118
|
+
}
|
|
1119
|
+
const fnNames = (rooms.functions || [])
|
|
1120
|
+
.slice(0, 25)
|
|
1121
|
+
.map(f => f.name)
|
|
1122
|
+
.filter(Boolean)
|
|
1123
|
+
.join(', ');
|
|
1124
|
+
const roomLines = (rooms.rooms || []).slice(0, 22).map(r => {
|
|
1125
|
+
const n = r.memberCount ?? (r.devices && r.devices.length) ?? 0;
|
|
1126
|
+
return ` • ${r.name} — about ${n} connected things`;
|
|
1127
|
+
});
|
|
1128
|
+
const lines = [
|
|
1129
|
+
'--- Facts for room/theme names only (rephrase themes in simple guest words; do not paste long labels) ---',
|
|
1130
|
+
];
|
|
1131
|
+
if (fnNames) {
|
|
1132
|
+
lines.push(`Theme labels from the home (rephrase, do not quote): ${fnNames}`);
|
|
1133
|
+
}
|
|
1134
|
+
lines.push('Rooms:', roomLines.length > 0 ? roomLines.join('\n') : ' (none defined)');
|
|
1135
|
+
return lines.join('\n');
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Build the LLM prompt for a specific HTML audience (user vs onboarding).
|
|
1140
|
+
*
|
|
1141
|
+
* @param {object} docModel Document model
|
|
1142
|
+
* @param {'user'|'onboarding'} audience Target profile
|
|
1143
|
+
* @param {string} langName Human-readable language, e.g. "German"
|
|
1144
|
+
* @param {string} langCode Adapter language code: de | en | fr
|
|
1145
|
+
* @returns {string} Prompt text
|
|
1146
|
+
*/
|
|
1147
|
+
/**
|
|
1148
|
+
* Build an optional owner-context block from aiOwnerHints config + manualContext fields.
|
|
1149
|
+
* User profile: hints are authoritative. Onboarding/guest: same facts, but the model must paraphrase
|
|
1150
|
+
* without IT vocabulary — otherwise post-filters replace the whole block with neutral text.
|
|
1151
|
+
*
|
|
1152
|
+
* @param {object} docModel Document model
|
|
1153
|
+
* @param {'user'|'onboarding'} audience Target reader
|
|
1154
|
+
* @returns {string} Formatted block or empty string
|
|
1155
|
+
*/
|
|
1156
|
+
function buildOwnerContextBlock(docModel, audience) {
|
|
1157
|
+
const config = docModel._adapterConfig || {};
|
|
1158
|
+
const manual = docModel.manualContext || {};
|
|
1159
|
+
const isGuest = audience === 'onboarding';
|
|
1160
|
+
|
|
1161
|
+
const parts = [];
|
|
1162
|
+
if (manual.description && manual.description.trim()) {
|
|
1163
|
+
parts.push(`Home description: ${manual.description.trim()}`);
|
|
1164
|
+
}
|
|
1165
|
+
if (manual.notes && manual.notes.trim()) {
|
|
1166
|
+
parts.push(`Admin notes: ${manual.notes.trim()}`);
|
|
1167
|
+
}
|
|
1168
|
+
if (manual.guestHelpNote && String(manual.guestHelpNote).trim()) {
|
|
1169
|
+
parts.push(`Help & emergencies (owner text): ${String(manual.guestHelpNote).trim()}`);
|
|
1170
|
+
}
|
|
1171
|
+
const qfPairs = [
|
|
1172
|
+
['Wi‑Fi/network', manual.troubleshootWifiHint],
|
|
1173
|
+
['Power/fuses', manual.troubleshootPowerHint],
|
|
1174
|
+
['Water shutoff', manual.troubleshootWaterHint],
|
|
1175
|
+
['Other', manual.troubleshootExtraHint],
|
|
1176
|
+
];
|
|
1177
|
+
for (const [label, val] of qfPairs) {
|
|
1178
|
+
if (val && String(val).trim()) {
|
|
1179
|
+
parts.push(`At-a-glance (${label}, owner): ${String(val).trim()}`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (manual.homeRoutinesNote && String(manual.homeRoutinesNote).trim()) {
|
|
1183
|
+
parts.push(`Routines in plain language (owner text): ${String(manual.homeRoutinesNote).trim()}`);
|
|
1184
|
+
}
|
|
1185
|
+
if (manual.ownerPlaybookNote && String(manual.ownerPlaybookNote).trim()) {
|
|
1186
|
+
parts.push(`Household playbook / procedures (owner text): ${String(manual.ownerPlaybookNote).trim()}`);
|
|
1187
|
+
}
|
|
1188
|
+
if (docModel.customDocSections && docModel.customDocSections.length) {
|
|
1189
|
+
const audienceProfile = isGuest ? 'onboarding' : 'user';
|
|
1190
|
+
const titles = docModel.customDocSections
|
|
1191
|
+
.filter(s => !s.profiles || !s.profiles.length || s.profiles.includes(audienceProfile))
|
|
1192
|
+
.map(s => s.title);
|
|
1193
|
+
if (titles.length) {
|
|
1194
|
+
parts.push(`Custom documentation sections (owner-defined): ${titles.join('; ')}`);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
const hints = config.aiOwnerHints && String(config.aiOwnerHints).trim();
|
|
1198
|
+
if (hints) {
|
|
1199
|
+
if (isGuest) {
|
|
1200
|
+
parts.push(
|
|
1201
|
+
[
|
|
1202
|
+
'Operator context hints (PRIVATE — do not paste software/project wording into guest text).',
|
|
1203
|
+
'Paraphrase in simple household language only. Forbidden in guest output: Adapter, Instanz, Repository/Repo, ioBroker, Broker, Skript, roadmap, Git, dev/repo jargon.',
|
|
1204
|
+
'If setup is still in progress, say it gently (e.g. "hier wird noch eingerichtet") without IT metaphors.',
|
|
1205
|
+
hints,
|
|
1206
|
+
].join('\n'),
|
|
1207
|
+
);
|
|
1208
|
+
} else {
|
|
1209
|
+
parts.push(`Owner context (use as authoritative facts for this home):\n${hints}`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (parts.length === 0) {
|
|
1213
|
+
return '';
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
const header = isGuest
|
|
1217
|
+
? 'Owner-provided context (private — paraphrase for guests; obey STRICTLY FORBIDDEN; keep warm host tone):'
|
|
1218
|
+
: 'Owner-provided context (treat as ground truth — do not contradict these facts):';
|
|
1219
|
+
|
|
1220
|
+
return `\n\n${header}\n${parts.join('\n')}`;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function buildAudiencePrompt(docModel, audience, langName, langCode) {
|
|
1224
|
+
const systemSummary = buildSystemSummary(docModel);
|
|
1225
|
+
const ownerContext = buildOwnerContextBlock(docModel, audience);
|
|
1226
|
+
|
|
1227
|
+
if (audience === 'user') {
|
|
1228
|
+
const deGuide =
|
|
1229
|
+
langCode === 'de' ? `${buildGermanResidentDuLanguageBlock()}${buildGermanUserResidentBlock()}` : '';
|
|
1230
|
+
const roomGrounding = buildRoomCapabilityGrounding(docModel);
|
|
1231
|
+
const quickOverviewGrounding = buildQuickStartGroundingForAi(docModel);
|
|
1232
|
+
return `You are a documentation assistant for a home automation system (ioBroker).
|
|
1233
|
+
Write entirely in ${langName}.
|
|
1234
|
+
${deGuide}
|
|
1235
|
+
Audience: people who live here and use the smart home daily. Clear, practical everyday language. No adapter IDs, OIDs, or technical paths.
|
|
1236
|
+
|
|
1237
|
+
System data:
|
|
1238
|
+
${systemSummary}${ownerContext}
|
|
1239
|
+
|
|
1240
|
+
${roomGrounding}
|
|
1241
|
+
${quickOverviewGrounding ? `\n${quickOverviewGrounding}\n` : ''}
|
|
1242
|
+
GROUNDING (mandatory): Base every concrete claim on "System data", the room capability block, and — if present — the "AutoDoc quick overview" grounding block (same facts as the printed Quick overview / Quick start sections). Only name rooms that appear there. Only describe home behaviours (lighting, heating, blinds, sensors, etc.) that match the listed categories for those rooms. Do not invent: garage pressure, scanning "bags", kitchen table occupancy, nonsense compounds (Lichtzuschnitt, Backup-Adaptation), fantasy products, furniture shops, backpacks, display cases, made-up room names, or surreal automation. If the export is sparse, write a shorter, honest summary instead of filler. Do not quote internal host/container hex ids to end users — use "your ioBroker server" or the project name.
|
|
1243
|
+
German spelling (mandatory): the product name is **ioBroker** (lowercase io, capital B) — never **iroBroker** or other typos. Use **Adapter** / **Adapter-Instanzen**, never mangled forms like **Adapatoren**. Do not invent device brands (e.g. **iRover**) or fictional staff (housekeepers, cleaners, "relaxation specialists").
|
|
1244
|
+
|
|
1245
|
+
Instructions:
|
|
1246
|
+
1. NARRATIVE: 5–8 sentences. Concrete overview tied to the real rooms/categories above and maintenance hints. Not generic filler. Stay matter-of-fact when mentioning maintenance or disabled components — no dramatic or surreal wording. Mention ioBroker only as plain household software the family uses, not as an admin audit report. Do not wrap the whole narrative in markdown **. German: for shades use **Jalousien** / **Rollläden** — never **Blinde** (wrong meaning). Do **not** repeat the English labels **NARRATIVE** or **RECOMMENDATIONS** inside the paragraph text — those words are only for the output structure below. No invented appliances or absurd objects (smart bottles, nonsense compounds); stay within plausible household wording from the grounding data. In German, address the household in **du** (family profile) — not formal **Sie**; do not pose as a support agent ("Ich helfe Ihnen", "wenden Sie sich an mich") — the box is generated documentation, not a person.
|
|
1247
|
+
2. RECOMMENDATIONS: 5–8 bullet lines. Actionable habits tied to the data and issues above. If there are almost no issues, still give 3–4 positive, useful habits that fit the actual setup. Do **not** tell users to blindly enable every disabled adapter — some may be off on purpose; phrase as "check whether … is intentional" / in German **prüf mal, ob … Absicht war**. Only mention backup/messaging products if they appear in the system data; no invented "security audits". No lines that are only \`*\` or empty bullets; no closing pleasantries ("Ich hoffe …"). Never use a bullet to explain what ioBroker "is" (e.g. "lediglich ein Programm") — no filler definitions. If German, use direct **du**-style lines — e.g. **Achte drauf, dass …**, **Lohnt sich …** — not formal **Überprüfen Sie …**; never plural **Überprüft …** to one reader.
|
|
1248
|
+
|
|
1249
|
+
Format your response exactly like this:
|
|
1250
|
+
NARRATIVE:
|
|
1251
|
+
<text>
|
|
1252
|
+
|
|
1253
|
+
RECOMMENDATIONS:
|
|
1254
|
+
<text>`;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const guestFacts = buildOnboardingSystemSummary(docModel);
|
|
1258
|
+
const detail = buildOnboardingDetailBlock(docModel);
|
|
1259
|
+
const roomGrounding = buildRoomCapabilityGrounding(docModel);
|
|
1260
|
+
|
|
1261
|
+
let langQuality = '';
|
|
1262
|
+
if (langCode === 'de') {
|
|
1263
|
+
langQuality = `${buildGermanNaturalLanguageBlock()}
|
|
1264
|
+
German onboarding specifics:
|
|
1265
|
+
- Narrative: concrete welcome (what guests might notice in named rooms), relaxed host tone — not a manual, not abstract philosophy of "smart living".
|
|
1266
|
+
- Every bullet must address guests with "Sie" (or "Bitte … Sie …"). Never du-imperatives: no lines starting with "Lies ", "Überprüfe ", "Aktualisiere ", "Halte ", "Aktiviere ", "Sei ".
|
|
1267
|
+
- Recommendations: hospitality only — e.g. room sections below, things may run by themselves, ask residents before changing anything, how to reach hosts. FORBIDDEN in recommendations: ioBroker or Broker, Adapter/Instanz, Skript, Manager, maintenance-score, documentation setup score or "Score", BackItUp, Telegram-Adapter, Gerätesuche, Webinterface, software updates, checking scripts, Warteschlange/Warteschlusszeit as fake words, English "Schedule(s)".
|
|
1268
|
+
- Stay on-topic: only this home, guests, residents, comfort, safety, house rules. No random tangents — never highway traffic, TV channels, broadcast/streaming/news, or other off-topic filler unless the facts explicitly mention them.
|
|
1269
|
+
- No English inside German: never "our", "home page(s)", or mixed sentences — say "die Abschnitte auf dieser Seite", "die Räume weiter unten".
|
|
1270
|
+
- Room names are neuter in German: **das** Arbeitszimmer, **das** Schlafzimmer (never "der Arbeitszimmer"). No broken invented words (e.g. "Schiebemögliche"); use normal words (Rollläden, Beschattung, Licht).
|
|
1271
|
+
- Window blinds / shades: English "blinds" means **Jalousien**, **Rollläden**, or **Beschattung** — never **Blinde** or **Blinden** (that means blind people). No nonsense phrases like "Wetter einzuklagen".
|
|
1272
|
+
- Do not wrap the entire NARRATIVE or entire RECOMMENDATIONS in markdown ** — plain sentences; no empty bullet lines.
|
|
1273
|
+
- No assistant meta-closings: never end with "Ich hoffe, diese Vorschläge …", "Vielen Dank für Ihre Aufmerksamkeit", or similar.
|
|
1274
|
+
- Tone: a friendly home host, not SaaS onboarding — avoid "Ich freue mich Sie kennenzulernen" / "komfortables Erlebnis". Prefer short warm welcomes ("Schön, dass Sie da sind").
|
|
1275
|
+
- Do not tell guests rooms run on "Algorithmen" or "sensitive algorithms" — plain language only.
|
|
1276
|
+
- Residents **offer** help / are available for questions — do not say residents "Hilfe anfragen" (that means they request help, wrong meaning).
|
|
1277
|
+
- Do not tell guests to study a "Funktionenübersicht" for software; they only browse this page for comfort and safety.
|
|
1278
|
+
- Never write a **system status report** for guests: no software version numbers, no host or container IDs, no counts of adapters/scripts/instances, no backup/Telegram product names, no "security audit" of the installation — that belongs in the resident profile only.
|
|
1279
|
+
- This is a **private home**, not an office: never **Vorgesetzte**, **Ihre Vorgesetzten**, or **Chef** — people who change settings are the **Bewohner** / **die hier wohnen** / **Haushalt**, not "superiors".
|
|
1280
|
+
- Avoid stiff or wrong calques: no "Natürlich möchten wir Sie auch wissen, dass …"; prefer direct, natural sentences. No marketing phrases like "erfülltes Wohnen". For navigation say "weiter unten auf dieser Seite" / "in den folgenden Kapiteln" — do not invent wrong UI labels like English **'Abschnitte'** in quotes unless that label truly appears in the page.
|
|
1281
|
+
- **Realistic German (private home, not hotel / not poetry):** Rooms and walls do not "live" or "work" — say "this home has …" / "you will find …". **das WC** (never **der WC**). Questions: **wenn Sie noch Fragen haben**, never broken **wenn Sie sich noch Fragen macht**. Lighting/heating: **an Ihre Bedürfnisse angepasst** or **läuft oft automatisch mit** — never nonsense like **auf Ihrem Bedürfnis ausgehen**. Guests **use or enter a room** — never **im Raum verbrauchen**. **die Bewohner** and **ihre Grenzen** — fix wrong **seiner Grenzen** when residents are plural. Do not invent **signage / Beschilderung** in a normal flat unless clearly plausible. No absurd advice (**den ruhigen Ort stören wenn Sie müde sind**). Bullets: start with "- " only; no lines that are only "*" or "\\*"; no duplicated meta lines (**Dazu steht gerne Hilfe** tacked onto another tip).
|
|
1282
|
+
- **German syntax (common model bugs):** Never end a phrase with **an die Bewohner vor** or **die Bewohner vor.** (dangling **vor**). For asking residents first, use a complete sentence — e.g. **Wenden Sie sich dazu bitte an die Bewohner** or **Sprechen Sie vorher mit den Bewohnern**. Do not write **wenden Sie sich … zu den folgenden Abschnitten**; use **In den folgenden Abschnitten finden Sie …** / **Weitere Infos finden Sie weiter unten**. Never **lassen Sie sich … unterstützt** — rephrase to **sich gern an die Bewohner wenden** or **sich helfen lassen**. Do not repeat the same thought in the NARRATIVE and in almost every bullet with slightly different words.
|
|
1283
|
+
`;
|
|
1284
|
+
} else if (langCode === 'fr') {
|
|
1285
|
+
langQuality = `
|
|
1286
|
+
French: use consistent polite "vous"; grammatically correct sentences; no random English words in the middle.
|
|
1287
|
+
`;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
return `You write the "welcome" box for GUESTS and FIRST-TIME VISITORS of a smart home.
|
|
1291
|
+
Write entirely in ${langName}. Tone: warm, calm, like a friendly host — not a technician and not marketing fluff.
|
|
1292
|
+
|
|
1293
|
+
Facts below are for reasoning only — paraphrase in simple daily language. Never copy technical nouns from lists into the guest text.
|
|
1294
|
+
|
|
1295
|
+
STRICTLY FORBIDDEN anywhere in the output (including substrings and any casing):
|
|
1296
|
+
- The letters "io" immediately followed by "Broker" as one word; also the standalone substring "Broker" in a product sense. Say "this home", "the automation here", "how things are set up here" instead.
|
|
1297
|
+
- Words: adapter, instance, driver, binding, OID, datapoint, Webinterface, Gerätesuche, Skript-Manager, maintenance-score, maintenance score, documentation setup score, BackItUp, Repository
|
|
1298
|
+
- Protocols / IT: MQTT, CoAP, REST, API, HTTP, HTTPS, JSON, WebSocket, IP
|
|
1299
|
+
- Messaging apps or bots by product name (Telegram, WhatsApp, …) — you may say "ask the hosts" / "send a message to your hosts" without naming apps
|
|
1300
|
+
- Programming: JavaScript, Blockly, Skript meaning code, Admin as a mode name
|
|
1301
|
+
- English housekeeping words in German output: Schedule, Score, our, home page(s) (use German or rephrase)
|
|
1302
|
+
- Invented passwords, codes, devices, or precise automation stories not supported by the facts (no AC warming food, no made-up sunrise scenes)
|
|
1303
|
+
- Unrelated real-world topics: no highways, traffic, television, streaming, news media — guests are reading about this home only
|
|
1304
|
+
|
|
1305
|
+
ALLOWED: room names from the data; generic words (lighting, heating, shutters, motion, door/window); "the sections below in this page", "ask the people who live here"; sensible comfort and safety tips.
|
|
1306
|
+
|
|
1307
|
+
Guest-oriented facts (internal):
|
|
1308
|
+
${guestFacts}${ownerContext}
|
|
1309
|
+
|
|
1310
|
+
${detail}
|
|
1311
|
+
|
|
1312
|
+
${roomGrounding}
|
|
1313
|
+
${langQuality}
|
|
1314
|
+
|
|
1315
|
+
GROUNDING (mandatory for guests): Your text must reflect ONLY this home's named rooms, optional function themes, and the observed device categories in the grounding block. Welcome and tips may paraphrase in plain language — but do NOT invent objects, shopping, furniture, backpacks, refrigerators, overnight stays, mobile shops, fantasy room names, or domestic scenes that are not supported by those facts. If the home has few devices, write a SHORT calm welcome plus simple etiquette (ask hosts, sections below) instead of creative stories. In German, never cast the household as a workplace: no **Vorgesetzte** / **Vorgesetzten** for who adjusts automations — use **Bewohner** or **die hier wohnen**.
|
|
1316
|
+
|
|
1317
|
+
Instructions:
|
|
1318
|
+
1. NARRATIVE: About 120–200 words max; shorter is fine if data is thin. Welcome them using only room names from the data. Mention capabilities only in broad household terms that match the categories (e.g. lighting, blinds, temperature) — never fake sensors or silly specifics. Do **not** summarize IT inventory (how many adapters, scripts, versions) — guests must not read an admin dashboard. Sound like a **real private household**, not a hotel manual: no personified rooms, no forced "house rules" that sound translated or absurd.
|
|
1319
|
+
2. RECOMMENDATIONS: 5–8 bullet lines — guest etiquette and orientation only (browse sections below, respect residents' settings, things may change automatically, whom to ask). Never administrator or installer tasks: no adapters, scripts, backups, updates, scores, or product names. Never tell guests to "switch on all adapters" or fix scripts. Each bullet one clear sentence; use "- " at the start of each line; no stray "*"-only lines.
|
|
1320
|
+
|
|
1321
|
+
Before you finish, re-read both sections: if any forbidden substring appears, rewrite. If the output mixes informal "du" with "Sie" in German, rewrite to consistent "Sie" only.
|
|
1322
|
+
|
|
1323
|
+
Format your response exactly like this:
|
|
1324
|
+
NARRATIVE:
|
|
1325
|
+
<text>
|
|
1326
|
+
|
|
1327
|
+
RECOMMENDATIONS:
|
|
1328
|
+
<text>`;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* AiEnhancer generates AI-powered narrative text to enrich the documentation.
|
|
1333
|
+
*/
|
|
1334
|
+
class AiEnhancer {
|
|
1335
|
+
/**
|
|
1336
|
+
* @param {object} adapter ioBroker adapter instance
|
|
1337
|
+
*/
|
|
1338
|
+
constructor(adapter) {
|
|
1339
|
+
this.adapter = adapter;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* @param {string} val Progress (e.g. "3/12") or "—" when idle
|
|
1344
|
+
* @returns {Promise<void>}
|
|
1345
|
+
*/
|
|
1346
|
+
async setScriptSourceProgress(val) {
|
|
1347
|
+
try {
|
|
1348
|
+
await this.adapter.setStateAsync('info.aiScriptSourceProgress', { val, ack: true });
|
|
1349
|
+
} catch {
|
|
1350
|
+
// ignore if state is missing
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Call the configured provider once with the given prompt.
|
|
1356
|
+
*
|
|
1357
|
+
* @param {object} config Adapter config
|
|
1358
|
+
* @param {string} provider Provider id (ollama, groq, …)
|
|
1359
|
+
* @param {string} model Model id
|
|
1360
|
+
* @param {string} prompt Prompt text
|
|
1361
|
+
* @param {number} maxTokens Max completion tokens
|
|
1362
|
+
* @param {string} [systemPrompt] Optional system message (OpenAI-compatible + Anthropic)
|
|
1363
|
+
* @param {number} [temperature] Optional sampling temperature (Anthropic clamped to 0–1)
|
|
1364
|
+
* @returns {Promise<string>} Raw model text, or empty string if misconfigured
|
|
1365
|
+
*/
|
|
1366
|
+
async invokeProvider(config, provider, model, prompt, maxTokens, systemPrompt, temperature) {
|
|
1367
|
+
const timeoutMs = parseRequestTimeoutMs(config);
|
|
1368
|
+
if (provider === 'anthropic') {
|
|
1369
|
+
const apiKey = (config.aiApiKey || '').trim();
|
|
1370
|
+
if (!apiKey) {
|
|
1371
|
+
this.adapter.log.warn('AI provider "anthropic" selected but no API key configured');
|
|
1372
|
+
return '';
|
|
1373
|
+
}
|
|
1374
|
+
return callAnthropic(apiKey, model, prompt, maxTokens, systemPrompt, temperature, timeoutMs);
|
|
1375
|
+
}
|
|
1376
|
+
if (provider === 'groq') {
|
|
1377
|
+
const apiKey = (config.aiApiKey || '').trim();
|
|
1378
|
+
if (!apiKey) {
|
|
1379
|
+
this.adapter.log.warn('AI provider "groq" selected but no API key configured');
|
|
1380
|
+
return '';
|
|
1381
|
+
}
|
|
1382
|
+
return callOpenAiCompatible(
|
|
1383
|
+
'https://api.groq.com',
|
|
1384
|
+
apiKey,
|
|
1385
|
+
model,
|
|
1386
|
+
prompt,
|
|
1387
|
+
maxTokens,
|
|
1388
|
+
systemPrompt,
|
|
1389
|
+
temperature,
|
|
1390
|
+
timeoutMs,
|
|
1391
|
+
this.adapter.log,
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
if (provider === 'ollama') {
|
|
1395
|
+
const baseUrl = (config.aiBaseUrl || 'http://localhost:11434').trim();
|
|
1396
|
+
return callOpenAiCompatible(
|
|
1397
|
+
baseUrl,
|
|
1398
|
+
'',
|
|
1399
|
+
model,
|
|
1400
|
+
prompt,
|
|
1401
|
+
maxTokens,
|
|
1402
|
+
systemPrompt,
|
|
1403
|
+
temperature,
|
|
1404
|
+
timeoutMs,
|
|
1405
|
+
this.adapter.log,
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
if (provider === 'mistral') {
|
|
1409
|
+
const apiKey = (config.aiApiKey || '').trim();
|
|
1410
|
+
if (!apiKey) {
|
|
1411
|
+
this.adapter.log.warn('AI provider "mistral" selected but no API key configured');
|
|
1412
|
+
return '';
|
|
1413
|
+
}
|
|
1414
|
+
return callOpenAiCompatible(
|
|
1415
|
+
'https://api.mistral.ai',
|
|
1416
|
+
apiKey,
|
|
1417
|
+
model,
|
|
1418
|
+
prompt,
|
|
1419
|
+
maxTokens,
|
|
1420
|
+
systemPrompt,
|
|
1421
|
+
temperature,
|
|
1422
|
+
timeoutMs,
|
|
1423
|
+
this.adapter.log,
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
this.adapter.log.warn(`Unknown AI provider: ${provider}`);
|
|
1427
|
+
return '';
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* Second LLM call: fix du/Sie mix and obvious German issues in onboarding text.
|
|
1432
|
+
*
|
|
1433
|
+
* @param {{ narrative: string, recommendations: string }} block
|
|
1434
|
+
* @param {string} provider
|
|
1435
|
+
* @param {string} model
|
|
1436
|
+
* @returns {Promise<{ narrative: string, recommendations: string } | null>}
|
|
1437
|
+
*/
|
|
1438
|
+
async polishGermanOnboardingSie(block, provider, model) {
|
|
1439
|
+
const prompt = buildGermanOnboardingPolishUserPrompt(block);
|
|
1440
|
+
try {
|
|
1441
|
+
const text = await this.invokeProvider(
|
|
1442
|
+
this.adapter.config,
|
|
1443
|
+
provider,
|
|
1444
|
+
model,
|
|
1445
|
+
prompt,
|
|
1446
|
+
MAX_TOKENS_ONBOARDING_POLISH,
|
|
1447
|
+
GERMAN_ONBOARDING_POLISH_SYSTEM,
|
|
1448
|
+
TEMPERATURE_ONBOARDING_POLISH,
|
|
1449
|
+
);
|
|
1450
|
+
if (!text) {
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
const parsed = this.parseResponse(text, 'de');
|
|
1454
|
+
if (isAiBlockEmpty(parsed)) {
|
|
1455
|
+
return null;
|
|
1456
|
+
}
|
|
1457
|
+
return parsed;
|
|
1458
|
+
} catch (err) {
|
|
1459
|
+
this.adapter.log.warn(`AI German onboarding polish pass failed: ${err.message}`);
|
|
1460
|
+
return null;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Second LLM call: fix Sie/du mix, plural imperatives, and obvious German issues in **user** (resident) text.
|
|
1466
|
+
*
|
|
1467
|
+
* @param {{ narrative: string, recommendations: string }} block
|
|
1468
|
+
* @param {string} provider
|
|
1469
|
+
* @param {string} model
|
|
1470
|
+
* @returns {Promise<{ narrative: string, recommendations: string } | null>}
|
|
1471
|
+
*/
|
|
1472
|
+
async polishGermanUserDe(block, provider, model) {
|
|
1473
|
+
const prompt = buildGermanUserPolishUserPrompt(block);
|
|
1474
|
+
try {
|
|
1475
|
+
const text = await this.invokeProvider(
|
|
1476
|
+
this.adapter.config,
|
|
1477
|
+
provider,
|
|
1478
|
+
model,
|
|
1479
|
+
prompt,
|
|
1480
|
+
MAX_TOKENS_ONBOARDING_POLISH,
|
|
1481
|
+
GERMAN_USER_POLISH_SYSTEM,
|
|
1482
|
+
TEMPERATURE_ONBOARDING_POLISH,
|
|
1483
|
+
);
|
|
1484
|
+
if (!text) {
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
const parsed = this.parseResponse(text, 'de');
|
|
1488
|
+
if (isAiBlockEmpty(parsed)) {
|
|
1489
|
+
return null;
|
|
1490
|
+
}
|
|
1491
|
+
return parsed;
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
this.adapter.log.warn(`AI German user-profile polish pass failed: ${err.message}`);
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Generate AI text for one HTML audience (user or onboarding).
|
|
1500
|
+
*
|
|
1501
|
+
* @param {object} docModel Document model
|
|
1502
|
+
* @param {'user'|'onboarding'} audience Target reader (user vs guest)
|
|
1503
|
+
* @param {string} provider Provider id
|
|
1504
|
+
* @param {string} model Model id
|
|
1505
|
+
* @param {string} langName Output language name for the prompt
|
|
1506
|
+
* @param {string} langCode Adapter language (de | en | fr)
|
|
1507
|
+
* @returns {Promise<{narrative: string, recommendations: string}|null>} Parsed sections or null
|
|
1508
|
+
*/
|
|
1509
|
+
async enhanceOneAudience(docModel, audience, provider, model, langName, langCode) {
|
|
1510
|
+
const prompt = buildAudiencePrompt(docModel, audience, langName, langCode || 'en');
|
|
1511
|
+
const lc = (langCode || 'en').toLowerCase();
|
|
1512
|
+
let systemPrompt = '';
|
|
1513
|
+
if (audience === 'onboarding') {
|
|
1514
|
+
systemPrompt = lc === 'de' ? ONBOARDING_SYSTEM_MESSAGE_DE : ONBOARDING_SYSTEM_MESSAGE;
|
|
1515
|
+
} else if (audience === 'user' && lc === 'de') {
|
|
1516
|
+
systemPrompt = USER_SYSTEM_MESSAGE_DE;
|
|
1517
|
+
}
|
|
1518
|
+
const maxTokens = audience === 'onboarding' ? MAX_TOKENS_ONBOARDING : MAX_TOKENS_USER;
|
|
1519
|
+
const cfg = this.adapter.config;
|
|
1520
|
+
let temperature = parseOptionalTemperature(
|
|
1521
|
+
audience === 'onboarding' ? cfg.aiTemperatureOnboarding : cfg.aiTemperatureUser,
|
|
1522
|
+
);
|
|
1523
|
+
if (temperature === undefined && provider === 'ollama') {
|
|
1524
|
+
temperature =
|
|
1525
|
+
audience === 'onboarding' ? OLLAMA_DEFAULT_TEMPERATURE_ONBOARDING : OLLAMA_DEFAULT_TEMPERATURE_USER;
|
|
1526
|
+
}
|
|
1527
|
+
try {
|
|
1528
|
+
this.adapter.log.info(
|
|
1529
|
+
`AI: requesting "${audience}" profile (max_tokens≈${maxTokens}${temperature !== undefined ? `, temp=${temperature}` : ''}) — waiting for ${provider}…`,
|
|
1530
|
+
);
|
|
1531
|
+
this.adapter.log.debug(
|
|
1532
|
+
`AI enhancement: provider=${provider}, model=${model}, audience=${audience}, max_tokens=${maxTokens}${
|
|
1533
|
+
temperature !== undefined ? `, temperature=${temperature}` : ''
|
|
1534
|
+
}`,
|
|
1535
|
+
);
|
|
1536
|
+
const text = await this.invokeProvider(cfg, provider, model, prompt, maxTokens, systemPrompt, temperature);
|
|
1537
|
+
this.adapter.log.info(`AI: "${audience}" profile — model response received, parsing…`);
|
|
1538
|
+
if (!text) {
|
|
1539
|
+
if (provider === 'ollama') {
|
|
1540
|
+
const showModel = model || DEFAULT_MODELS.ollama;
|
|
1541
|
+
this.adapter.log.warn(
|
|
1542
|
+
`AI: empty response from Ollama (${audience}) — is model "${showModel}" installed (\`ollama list\`), is the container up, and is the Ollama base URL reachable from the ioBroker host?`,
|
|
1543
|
+
);
|
|
1544
|
+
} else {
|
|
1545
|
+
this.adapter.log.warn(`AI: empty API response (${provider}, ${audience}).`);
|
|
1546
|
+
}
|
|
1547
|
+
return null;
|
|
1548
|
+
}
|
|
1549
|
+
let parsed = this.parseResponse(text, langCode || 'en');
|
|
1550
|
+
if (audience === 'onboarding' && lc === 'de' && parsed) {
|
|
1551
|
+
parsed = { ...parsed, ...applyGermanGuestDeterministicFixes(parsed.narrative, parsed.recommendations) };
|
|
1552
|
+
}
|
|
1553
|
+
if (isAiBlockEmpty(parsed)) {
|
|
1554
|
+
const excerpt = text.length > 400 ? `${text.slice(0, 400)}…` : text;
|
|
1555
|
+
this.adapter.log.warn(
|
|
1556
|
+
`AI enhancement returned no usable text (${provider}, ${audience}) — could not parse narrative + recommendations. Raw excerpt: ${excerpt.replace(/\s+/g, ' ').trim()}`,
|
|
1557
|
+
);
|
|
1558
|
+
return null;
|
|
1559
|
+
}
|
|
1560
|
+
if (
|
|
1561
|
+
audience === 'user' &&
|
|
1562
|
+
lc === 'de' &&
|
|
1563
|
+
germanUserProfileNeedsPolish(parsed.narrative, parsed.recommendations)
|
|
1564
|
+
) {
|
|
1565
|
+
const polishedUser = await this.polishGermanUserDe(parsed, provider, model);
|
|
1566
|
+
if (polishedUser) {
|
|
1567
|
+
this.adapter.log.info('AI: German user profile — applied second pass (du + grammar / noise)');
|
|
1568
|
+
parsed = polishedUser;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
if (
|
|
1572
|
+
audience === 'onboarding' &&
|
|
1573
|
+
lc === 'de' &&
|
|
1574
|
+
germanOnboardingNeedsSiePolish(parsed.narrative, parsed.recommendations)
|
|
1575
|
+
) {
|
|
1576
|
+
const polished = await this.polishGermanOnboardingSie(parsed, provider, model);
|
|
1577
|
+
if (polished) {
|
|
1578
|
+
this.adapter.log.info('AI: German onboarding — applied second pass (Sie consistency / grammar)');
|
|
1579
|
+
parsed = polished;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
if (audience === 'onboarding' && lc === 'de' && parsed) {
|
|
1583
|
+
parsed = { ...parsed, ...applyGermanGuestDeterministicFixes(parsed.narrative, parsed.recommendations) };
|
|
1584
|
+
}
|
|
1585
|
+
if (
|
|
1586
|
+
audience === 'onboarding' &&
|
|
1587
|
+
onboardingTextLooksLikeTechnicalDump(parsed.narrative, parsed.recommendations)
|
|
1588
|
+
) {
|
|
1589
|
+
this.adapter.log.warn(
|
|
1590
|
+
'AI: onboarding text looked like admin/resident technical summary — replaced with neutral guest wording for autodoc-onboarding.html. If you use "KI-Kontexthinweise", avoid words like Adapter/Repo/ioBroker there or the model may quote them and trigger this safety replace.',
|
|
1591
|
+
);
|
|
1592
|
+
parsed = getNeutralOnboardingGuestBlock(langCode || 'en');
|
|
1593
|
+
}
|
|
1594
|
+
if (
|
|
1595
|
+
audience === 'onboarding' &&
|
|
1596
|
+
lc === 'de' &&
|
|
1597
|
+
germanOnboardingGuestStillUnacceptable(parsed.narrative, parsed.recommendations)
|
|
1598
|
+
) {
|
|
1599
|
+
this.adapter.log.warn(
|
|
1600
|
+
'AI: German onboarding failed guest quality gate — replaced with neutral guest wording for autodoc-onboarding.html.',
|
|
1601
|
+
);
|
|
1602
|
+
parsed = getNeutralOnboardingGuestBlock(langCode || 'en');
|
|
1603
|
+
}
|
|
1604
|
+
if (
|
|
1605
|
+
audience === 'user' &&
|
|
1606
|
+
lc === 'de' &&
|
|
1607
|
+
germanUserAiStillUnacceptable(parsed.narrative, parsed.recommendations)
|
|
1608
|
+
) {
|
|
1609
|
+
this.adapter.log.warn(
|
|
1610
|
+
'AI: German user text failed quality gate — using short factual summary for autodoc-user.html.',
|
|
1611
|
+
);
|
|
1612
|
+
parsed = buildGermanUserFallbackBlock(docModel);
|
|
1613
|
+
}
|
|
1614
|
+
return parsed;
|
|
1615
|
+
} catch (err) {
|
|
1616
|
+
this.adapter.log.warn(`AI enhancement failed (${provider}, ${audience}): ${err.message}`);
|
|
1617
|
+
return null;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Generate AI-enhanced narrative for User and Onboarding HTML (two tailored calls).
|
|
1623
|
+
* Returns null if disabled or if both API calls fail.
|
|
1624
|
+
*
|
|
1625
|
+
* @param {object} docModel Document model
|
|
1626
|
+
* @param {object} [rawData] Optional raw discovery data (for script source analysis)
|
|
1627
|
+
* @returns {Promise<{user: object|null, onboarding: object|null, meta?: object}|null>} Per-profile AI blocks + optional meta
|
|
1628
|
+
*/
|
|
1629
|
+
async enhance(docModel, rawData) {
|
|
1630
|
+
const config = this.adapter.config;
|
|
1631
|
+
const provider = (config.aiProvider || 'none').trim();
|
|
1632
|
+
|
|
1633
|
+
if (!config.aiAnalyzeScriptSources) {
|
|
1634
|
+
await this.setScriptSourceProgress('—');
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
if (provider === 'none') {
|
|
1638
|
+
this.adapter.log.debug('AI enhancement skipped (provider is Disabled).');
|
|
1639
|
+
await this.setScriptSourceProgress('—');
|
|
1640
|
+
return null;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
const model = (config.aiModel || DEFAULT_MODELS[provider] || '').trim();
|
|
1644
|
+
const lang = config.language || 'en';
|
|
1645
|
+
const langName = LANG_NAMES[lang] || 'English';
|
|
1646
|
+
|
|
1647
|
+
this.adapter.log.info(`AI enhancement starting: provider=${provider}, model=${model || '(default)'}`);
|
|
1648
|
+
|
|
1649
|
+
// Sequential calls: parallel requests often overload local Ollama (second call fails or times out).
|
|
1650
|
+
const userAi = await this.enhanceOneAudience(docModel, 'user', provider, model, langName, lang);
|
|
1651
|
+
this.adapter.log.info(
|
|
1652
|
+
'AI: user/family (resident) profile finished — starting guest/onboarding profile (second LLM call; local models may need many minutes each).',
|
|
1653
|
+
);
|
|
1654
|
+
const onboardingAi = await this.enhanceOneAudience(docModel, 'onboarding', provider, model, langName, lang);
|
|
1655
|
+
|
|
1656
|
+
let u = userAi;
|
|
1657
|
+
let o = onboardingAi;
|
|
1658
|
+
|
|
1659
|
+
if (!o && u) {
|
|
1660
|
+
this.adapter.log.info(
|
|
1661
|
+
'AI: onboarding block missing — using neutral guest placeholder for autodoc-onboarding.html (user-profile KI text is not copied to guests). Comment: <!-- autodoc-ai:onboarding source=fallback-neutral -->.',
|
|
1662
|
+
);
|
|
1663
|
+
o = getNeutralOnboardingGuestBlock(lang);
|
|
1664
|
+
}
|
|
1665
|
+
if (!u && o) {
|
|
1666
|
+
this.adapter.log.info('AI: user block missing — reusing onboarding KI text for autodoc-user.html');
|
|
1667
|
+
u = { narrative: o.narrative, recommendations: o.recommendations };
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
if (!u && !o) {
|
|
1671
|
+
this.adapter.log.warn(
|
|
1672
|
+
'AI enhancement finished with no usable user or onboarding text — check warnings above (empty Ollama response, timeout, wrong model id, or unreachable base URL). Documentation HTML is still generated without the AI box.',
|
|
1673
|
+
);
|
|
1674
|
+
await this.setScriptSourceProgress('—');
|
|
1675
|
+
return null;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
this.adapter.log.info('AI enhancement finished successfully (user and/or onboarding block present).');
|
|
1679
|
+
|
|
1680
|
+
const onboardingFromUserFallback = !onboardingAi && !!u;
|
|
1681
|
+
const userFromOnboardingFallback = !userAi && !!o;
|
|
1682
|
+
|
|
1683
|
+
if (config.aiAnalyzeScriptSources && rawData && rawData.scripts) {
|
|
1684
|
+
await this.enhanceScriptSources(docModel, rawData, provider, model, langName, lang);
|
|
1685
|
+
} else if (config.aiAnalyzeScriptSources) {
|
|
1686
|
+
await this.setScriptSourceProgress('—');
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
return {
|
|
1690
|
+
user: u,
|
|
1691
|
+
onboarding: o,
|
|
1692
|
+
meta: {
|
|
1693
|
+
onboardingFromUserFallback,
|
|
1694
|
+
userFromOnboardingFallback,
|
|
1695
|
+
},
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* Optional: short AI explanation per enabled script (User/Onboarding HTML + Markdown).
|
|
1701
|
+
* Mutates docModel.scripts.scripts[].aiSummary; may set docModel.scripts.aiAutomationOverview.
|
|
1702
|
+
*
|
|
1703
|
+
* @param {object} docModel
|
|
1704
|
+
* @param {object} rawData
|
|
1705
|
+
* @param {string} provider
|
|
1706
|
+
* @param {string} model
|
|
1707
|
+
* @param {string} langName
|
|
1708
|
+
* @param {string} _lang Adapter language code (reserved)
|
|
1709
|
+
* @returns {Promise<void>}
|
|
1710
|
+
*/
|
|
1711
|
+
async enhanceScriptSources(docModel, rawData, provider, model, langName, _lang) {
|
|
1712
|
+
if (this.adapter.isScriptSourceAiCancelRequested()) {
|
|
1713
|
+
this.adapter.log.info(
|
|
1714
|
+
'AI script source phase: cancel was already requested — skipping all script requests.',
|
|
1715
|
+
);
|
|
1716
|
+
await this.setScriptSourceProgress('cancelled');
|
|
1717
|
+
this.adapter.clearScriptSourceAiCancelRequest();
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
const scripts = rawData.scripts || [];
|
|
1722
|
+
const byId = new Map(scripts.map(s => [s.id, s]));
|
|
1723
|
+
const rows = (docModel.scripts && docModel.scripts.scripts) || [];
|
|
1724
|
+
let successCount = 0;
|
|
1725
|
+
const summariesForOverview = [];
|
|
1726
|
+
const maxScriptChars = parseMaxScriptCharsForAi(this.adapter.config);
|
|
1727
|
+
|
|
1728
|
+
let totalEligible = 0;
|
|
1729
|
+
for (const row of rows) {
|
|
1730
|
+
if (!row.enabled) {
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
const r = byId.get(row.id);
|
|
1734
|
+
if (!r || !String(r.source || '').trim()) {
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
totalEligible++;
|
|
1738
|
+
}
|
|
1739
|
+
if (totalEligible === 0) {
|
|
1740
|
+
await this.setScriptSourceProgress('—');
|
|
1741
|
+
this.adapter.clearScriptSourceAiCancelRequest();
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
let invokeDone = 0;
|
|
1745
|
+
let userCancelled = false;
|
|
1746
|
+
await this.setScriptSourceProgress(`0/${totalEligible}`);
|
|
1747
|
+
|
|
1748
|
+
try {
|
|
1749
|
+
for (const row of rows) {
|
|
1750
|
+
if (this.adapter.isScriptSourceAiCancelRequested()) {
|
|
1751
|
+
this.adapter.log.info(
|
|
1752
|
+
'AI script source phase: cancel requested — stopping; optional overview is skipped. Ongoing KI call may still finish; the next script will not start.',
|
|
1753
|
+
);
|
|
1754
|
+
userCancelled = true;
|
|
1755
|
+
break;
|
|
1756
|
+
}
|
|
1757
|
+
if (!row.enabled || successCount >= MAX_SCRIPT_SOURCE_AI) {
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
const raw = byId.get(row.id);
|
|
1761
|
+
if (!raw || !String(raw.source || '').trim()) {
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
const body = truncateScriptSource(redactScriptSourceForAi(raw.source), maxScriptChars);
|
|
1765
|
+
const prompt = `Script name: ${row.name}
|
|
1766
|
+
Folder: ${row.folder || '(root)'}
|
|
1767
|
+
Trigger type: ${row.triggerType || 'unknown'}
|
|
1768
|
+
Schedule: ${row.schedule || 'none'}
|
|
1769
|
+
|
|
1770
|
+
JavaScript (sanitized, may be truncated):
|
|
1771
|
+
${body}
|
|
1772
|
+
|
|
1773
|
+
Task: In 2–4 short sentences, explain what this home automation script does for residents, in ${langName}. Use everyday language — no programming jargon, no brand names unless they appear in the code. Plain text only (no markdown headings).`;
|
|
1774
|
+
const sys = `You help document smart-home automations for non-technical readers. Be factual; if unclear, say what is uncertain. Language: ${langName}.`;
|
|
1775
|
+
try {
|
|
1776
|
+
const text = await this.invokeProvider(
|
|
1777
|
+
this.adapter.config,
|
|
1778
|
+
provider,
|
|
1779
|
+
model,
|
|
1780
|
+
prompt,
|
|
1781
|
+
MAX_TOKENS_SCRIPT_SUMMARY,
|
|
1782
|
+
sys,
|
|
1783
|
+
0.25,
|
|
1784
|
+
);
|
|
1785
|
+
const cleaned = stripMarkdownFences(String(text || '').trim())
|
|
1786
|
+
.replace(/\s+/g, ' ')
|
|
1787
|
+
.trim();
|
|
1788
|
+
if (cleaned) {
|
|
1789
|
+
row.aiSummary = cleaned;
|
|
1790
|
+
summariesForOverview.push({ name: row.name, summary: cleaned });
|
|
1791
|
+
successCount++;
|
|
1792
|
+
}
|
|
1793
|
+
} catch (err) {
|
|
1794
|
+
this.adapter.log.debug(`AI script summary skipped for ${row.id}: ${err.message}`);
|
|
1795
|
+
} finally {
|
|
1796
|
+
invokeDone += 1;
|
|
1797
|
+
await this.setScriptSourceProgress(`${invokeDone}/${totalEligible}`);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
if (!userCancelled && summariesForOverview.length >= 2) {
|
|
1802
|
+
const bullet = summariesForOverview
|
|
1803
|
+
.slice(0, 24)
|
|
1804
|
+
.map(s => `- ${s.name}: ${s.summary}`)
|
|
1805
|
+
.join('\n');
|
|
1806
|
+
const overviewPrompt = `Below are short summaries of JavaScript automations in one smart home (language ${langName}). Write ONE short paragraph (4–6 sentences) for residents describing what kinds of automation run in this home overall — themes only, no technical detail, no bullet list in the output.
|
|
1807
|
+
|
|
1808
|
+
${bullet}`;
|
|
1809
|
+
try {
|
|
1810
|
+
const ov = await this.invokeProvider(
|
|
1811
|
+
this.adapter.config,
|
|
1812
|
+
provider,
|
|
1813
|
+
model,
|
|
1814
|
+
overviewPrompt,
|
|
1815
|
+
500,
|
|
1816
|
+
`Write in ${langName} for household residents.`,
|
|
1817
|
+
0.3,
|
|
1818
|
+
);
|
|
1819
|
+
const ovc = stripMarkdownFences(String(ov || '').trim());
|
|
1820
|
+
if (ovc) {
|
|
1821
|
+
docModel.scripts.aiAutomationOverview = ovc;
|
|
1822
|
+
}
|
|
1823
|
+
} catch (err) {
|
|
1824
|
+
this.adapter.log.debug(`AI automation overview skipped: ${err.message}`);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
} finally {
|
|
1828
|
+
this.adapter.clearScriptSourceAiCancelRequest();
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
if (userCancelled) {
|
|
1832
|
+
await this.setScriptSourceProgress('cancelled');
|
|
1833
|
+
this.adapter.log.info(
|
|
1834
|
+
`AI script-source phase ended after cancel — ${successCount} script(s) with summaries; optional automation overview not run.`,
|
|
1835
|
+
);
|
|
1836
|
+
} else if (successCount > 0) {
|
|
1837
|
+
this.adapter.log.info(`AI script-source summaries: ${successCount} script(s).`);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/**
|
|
1842
|
+
* Parse the structured API response into narrative and recommendations.
|
|
1843
|
+
* Accepts several model styles: plain NARRATIVE:/RECOMMENDATIONS:, Markdown headers, German labels, bullet-only tails.
|
|
1844
|
+
*
|
|
1845
|
+
* @param {string} text Raw API response text
|
|
1846
|
+
* @param {string} [langCode] Adapter language for fallback line (de | en | fr)
|
|
1847
|
+
* @returns {{narrative: string, recommendations: string}} Extracted sections (may be empty strings)
|
|
1848
|
+
*/
|
|
1849
|
+
parseResponse(text, langCode = 'en') {
|
|
1850
|
+
return parseAiSections(text, langCode);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
/**
|
|
1855
|
+
* Strip optional markdown code fences from model output.
|
|
1856
|
+
*
|
|
1857
|
+
* @param {string} s
|
|
1858
|
+
* @returns {string}
|
|
1859
|
+
*/
|
|
1860
|
+
function stripMarkdownFences(s) {
|
|
1861
|
+
let t = (s || '').trim();
|
|
1862
|
+
if (!t) {
|
|
1863
|
+
return '';
|
|
1864
|
+
}
|
|
1865
|
+
t = t.replace(/^```(?:[a-z]+)?\s*\r?\n/i, '');
|
|
1866
|
+
t = t.replace(/\r?\n```\s*$/i, '');
|
|
1867
|
+
return t.trim();
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/**
|
|
1871
|
+
* Models often put **Empfehlungen:** mid-paragraph without a newline — split-friendly marker.
|
|
1872
|
+
*
|
|
1873
|
+
* @param {string} t
|
|
1874
|
+
* @returns {string}
|
|
1875
|
+
*/
|
|
1876
|
+
function normalizeInlineRecommendationHeaders(t) {
|
|
1877
|
+
let s = t;
|
|
1878
|
+
s = s.replace(
|
|
1879
|
+
/([.!?…])\s*\*{0,2}\s*(?:Empfehlungen|Recommendations)\s*:\s*\*{0,2}\s*/gi,
|
|
1880
|
+
'$1\n\nRECOMMENDATIONS:\n',
|
|
1881
|
+
);
|
|
1882
|
+
s = s.replace(/\s*\*{0,2}\s*(?:Empfehlungen|Recommendations)\s*:\s*\*{0,2}\s*/gi, '\n\nRECOMMENDATIONS:\n');
|
|
1883
|
+
return s;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/**
|
|
1887
|
+
* Remove empty / marker-only bullet lines from a recommendations block.
|
|
1888
|
+
*
|
|
1889
|
+
* @param {string} rec
|
|
1890
|
+
* @returns {string}
|
|
1891
|
+
*/
|
|
1892
|
+
function stripEmptyRecommendationLines(rec) {
|
|
1893
|
+
const lines = (rec || '').split(/\r?\n/);
|
|
1894
|
+
const out = [];
|
|
1895
|
+
for (const line of lines) {
|
|
1896
|
+
let normalized = line.replace(/^(\s*[-*•]\s*)\\+\*/, '$1');
|
|
1897
|
+
const x = normalized.trim();
|
|
1898
|
+
if (!x) {
|
|
1899
|
+
continue;
|
|
1900
|
+
}
|
|
1901
|
+
if (/^[-*•]\s*$/.test(x)) {
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
if (/^[-*•]\s*[*•]+\s*$/.test(x)) {
|
|
1905
|
+
continue;
|
|
1906
|
+
}
|
|
1907
|
+
if (/^[-*•]\s*\\\*\s*$/.test(x)) {
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
if (/^\*\s*$/.test(x) || /^\\\*\s*$/.test(x)) {
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
out.push(normalized);
|
|
1914
|
+
}
|
|
1915
|
+
return out.join('\n').trim();
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/**
|
|
1919
|
+
* Drop resident-profile filler bullets the model often appends (defines ioBroker as "just a program").
|
|
1920
|
+
*
|
|
1921
|
+
* @param {string} rec
|
|
1922
|
+
* @returns {string}
|
|
1923
|
+
*/
|
|
1924
|
+
function stripNoiseRecommendationLines(rec) {
|
|
1925
|
+
const lines = (rec || '').split(/\r?\n/);
|
|
1926
|
+
const out = [];
|
|
1927
|
+
for (const line of lines) {
|
|
1928
|
+
const body = line.replace(/^[-*•]\s*/, '').trim();
|
|
1929
|
+
if (
|
|
1930
|
+
/ioBroker.*\b(lediglich|nur)\s+ei(n|ne)\s+Programm\b/i.test(body) ||
|
|
1931
|
+
/\b(lediglich|nur)\s+ei(n|ne)\s+Programm\b.*ioBroker/i.test(body)
|
|
1932
|
+
) {
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1935
|
+
if (/iRover|\bIr[oó]ver\b|Entspannung.*Spezialist|Haushälter|Haushalter\b/i.test(body)) {
|
|
1936
|
+
continue;
|
|
1937
|
+
}
|
|
1938
|
+
out.push(line);
|
|
1939
|
+
}
|
|
1940
|
+
return out.join('\n').trim();
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Strip model meta / echoed format labels so the HTML box does not show "Hier ist der Text: NARRATIVE:".
|
|
1945
|
+
*
|
|
1946
|
+
* @param {string} narrative Parsed narrative body (may contain echoed headers).
|
|
1947
|
+
* @param {string} recommendations Parsed recommendations body.
|
|
1948
|
+
* @returns {{ narrative: string, recommendations: string }} Sanitized parts for rendering.
|
|
1949
|
+
*/
|
|
1950
|
+
function cleanParsedAiParts(narrative, recommendations) {
|
|
1951
|
+
let n = (narrative || '').trim();
|
|
1952
|
+
let r = (recommendations || '').trim();
|
|
1953
|
+
n = n.replace(/^\s*Hier ist (?:der|die|das)\s+[^:]+:\s*/i, '');
|
|
1954
|
+
n = n.replace(/^\s*Here is (?:the|your) (?:text|response|output):\s*/i, '');
|
|
1955
|
+
let prev;
|
|
1956
|
+
do {
|
|
1957
|
+
prev = n;
|
|
1958
|
+
n = n.replace(/^\s*NARRATIVE\s*:\s*/i, '').trim();
|
|
1959
|
+
} while (n !== prev);
|
|
1960
|
+
// Models sometimes echo "NARRATIVE:" again mid-paragraph — strip those markers from the body
|
|
1961
|
+
do {
|
|
1962
|
+
prev = n;
|
|
1963
|
+
n = n.replace(/\n+\s*NARRATIVE\s*:\s*/gi, '\n\n').trim();
|
|
1964
|
+
} while (n !== prev);
|
|
1965
|
+
n = n.replace(/^\s*NARRATIVE\s*:\s*/gim, '').trim();
|
|
1966
|
+
r = r.replace(/^\s*RECOMMENDATIONS\s*:\s*/i, '').trim();
|
|
1967
|
+
do {
|
|
1968
|
+
prev = r;
|
|
1969
|
+
r = r.replace(/\n+\s*RECOMMENDATIONS\s*:\s*/gi, '\n\n').trim();
|
|
1970
|
+
} while (r !== prev);
|
|
1971
|
+
// Orphan / outer markdown bold from small models (whole paragraph wrapped in **)
|
|
1972
|
+
for (let i = 0; i < 4; i++) {
|
|
1973
|
+
const n2 = n
|
|
1974
|
+
.replace(/^\*\*\s*/, '')
|
|
1975
|
+
.replace(/\s*\*\*\s*$/, '')
|
|
1976
|
+
.trim();
|
|
1977
|
+
const r2 = r
|
|
1978
|
+
.replace(/^\*\*\s*/, '')
|
|
1979
|
+
.replace(/\s*\*\*\s*$/, '')
|
|
1980
|
+
.trim();
|
|
1981
|
+
if (n2 === n && r2 === r) {
|
|
1982
|
+
break;
|
|
1983
|
+
}
|
|
1984
|
+
n = n2;
|
|
1985
|
+
r = r2;
|
|
1986
|
+
}
|
|
1987
|
+
// Assistant "sign-off" tails (often appended after the last bullet on one line)
|
|
1988
|
+
const stripOutro = s => {
|
|
1989
|
+
let x = s.trim();
|
|
1990
|
+
x = x.replace(/\s*Ich hoffe,?\s+diese\s+Vorschläge\s+entsprechen\s+Ihren\s+Erwartungen!?\s*$/i, '');
|
|
1991
|
+
x = x.replace(/\s*Vielen\s+Dank\s+für\s+Ihre\s+Aufmerksamkeit!?\s*$/i, '');
|
|
1992
|
+
x = x.replace(/\s*I\s+hope\s+this\s+(?:helps|meets\s+your\s+expectations)[^.!?]*[.!?]?\s*$/i, '');
|
|
1993
|
+
return x.trim();
|
|
1994
|
+
};
|
|
1995
|
+
r = stripOutro(r);
|
|
1996
|
+
n = stripOutro(n);
|
|
1997
|
+
r = stripNoiseRecommendationLines(stripEmptyRecommendationLines(r));
|
|
1998
|
+
return { narrative: n, recommendations: r };
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* @param {string} text
|
|
2003
|
+
* @param {string} langCode
|
|
2004
|
+
* @returns {{ narrative: string, recommendations: string }}
|
|
2005
|
+
*/
|
|
2006
|
+
function parseAiSections(text, langCode = 'en') {
|
|
2007
|
+
let t = stripMarkdownFences(text);
|
|
2008
|
+
if (!t) {
|
|
2009
|
+
return { narrative: '', recommendations: '' };
|
|
2010
|
+
}
|
|
2011
|
+
t = normalizeInlineRecommendationHeaders(t);
|
|
2012
|
+
|
|
2013
|
+
const fallbackRec =
|
|
2014
|
+
langCode === 'de'
|
|
2015
|
+
? '- Einzelheiten und Listen finden Sie in den folgenden Kapiteln dieser Seite.'
|
|
2016
|
+
: langCode === 'fr'
|
|
2017
|
+
? '- Voir les sections ci-dessous pour le détail.'
|
|
2018
|
+
: '- See the documentation sections below for details.';
|
|
2019
|
+
|
|
2020
|
+
const splitPatterns = [
|
|
2021
|
+
{ rec: /\n\s*RECOMMENDATIONS\s*:\s*/i, stripHead: /^\s*NARRATIVE\s*:\s*/i },
|
|
2022
|
+
{ rec: /\n\s*Recommendations\s*:\s*/i, stripHead: /^\s*Narrative\s*:\s*/i },
|
|
2023
|
+
{
|
|
2024
|
+
rec: /\n\s*(?:#{1,4}\s*|\*\*)RECOMMENDATIONS(?:\*\*)?\s*:?\s*\n/i,
|
|
2025
|
+
stripHead: /^\s*(?:#{1,4}\s*|\*\*)NARRATIVE(?:\*\*)?\s*:?\s*\n/i,
|
|
2026
|
+
},
|
|
2027
|
+
{ rec: /\n\s*Empfehlungen\s*:\s*/i, stripHead: /^\s*(?:NARRATIVE|Zusammenfassung|Überblick)\s*:\s*/i },
|
|
2028
|
+
{
|
|
2029
|
+
rec: /\n\s*(?:#{1,4}\s*|\*\*)?(?:Empfehlungen|Tipps\s+für\s+Sie)\s*(?:\([^)]*\))?\s*:?\s*\n/i,
|
|
2030
|
+
stripHead:
|
|
2031
|
+
/^\s*(?:#{1,4}\s*|\*\*)?(?:NARRATIVE|Zusammenfassung|Überblick|KI-Zusammenfassung)(?:\*\*)?\s*:?\s*\n/i,
|
|
2032
|
+
},
|
|
2033
|
+
];
|
|
2034
|
+
|
|
2035
|
+
for (const { rec, stripHead } of splitPatterns) {
|
|
2036
|
+
const parts = t.split(rec);
|
|
2037
|
+
if (parts.length >= 2) {
|
|
2038
|
+
const narrative = parts[0].replace(stripHead, '').trim();
|
|
2039
|
+
const recommendations = parts
|
|
2040
|
+
.slice(1)
|
|
2041
|
+
.join('\n\n')
|
|
2042
|
+
.replace(/^\s*(?:RECOMMENDATIONS|Empfehlungen)\s*:\s*/i, '')
|
|
2043
|
+
.trim();
|
|
2044
|
+
if (narrative || recommendations) {
|
|
2045
|
+
return cleanParsedAiParts(narrative, recommendations);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
const recParts = t.split(/\n\s*RECOMMENDATIONS\s*:\s*/i);
|
|
2051
|
+
if (recParts.length >= 2) {
|
|
2052
|
+
const narrative = recParts[0].replace(/^\s*NARRATIVE\s*:\s*/i, '').trim();
|
|
2053
|
+
const recommendations = recParts.slice(1).join('\n\n').trim();
|
|
2054
|
+
if (narrative || recommendations) {
|
|
2055
|
+
return cleanParsedAiParts(narrative, recommendations);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
const narrativeMatch = t.match(/NARRATIVE:\s*([\s\S]*?)(?=RECOMMENDATIONS:|$)/i);
|
|
2060
|
+
const recommendationsMatch = t.match(/RECOMMENDATIONS:\s*([\s\S]*?)$/i);
|
|
2061
|
+
let narrative = (narrativeMatch?.[1] || '').trim();
|
|
2062
|
+
let recommendations = (recommendationsMatch?.[1] || '').trim();
|
|
2063
|
+
if (narrative || recommendations) {
|
|
2064
|
+
return cleanParsedAiParts(narrative, recommendations);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// Bullet list at end = recommendations, body = narrative
|
|
2068
|
+
const lines = t.split(/\r?\n/);
|
|
2069
|
+
let firstBullet = -1;
|
|
2070
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2071
|
+
if (/^\s*(?:[-*•]|\d+[.)])\s+\S/.test(lines[i])) {
|
|
2072
|
+
firstBullet = i;
|
|
2073
|
+
break;
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
if (firstBullet > 0) {
|
|
2077
|
+
narrative = lines.slice(0, firstBullet).join('\n').trim();
|
|
2078
|
+
recommendations = lines.slice(firstBullet).join('\n').trim();
|
|
2079
|
+
if (narrative && recommendations) {
|
|
2080
|
+
return cleanParsedAiParts(narrative, recommendations);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// Explicit **Tipps** / **Empfehlungen** section (markdown, no NARRATIVE: headers)
|
|
2085
|
+
const tipSection = /\n\*\*(?:Tipps?|Empfehlungen|Hinweise|Bitte beachten Sie)\*\*\s*:?\s*\n/i;
|
|
2086
|
+
const tipIdx = t.search(tipSection);
|
|
2087
|
+
if (tipIdx > 10) {
|
|
2088
|
+
narrative = t.slice(0, tipIdx).trim();
|
|
2089
|
+
recommendations = t.slice(tipIdx).replace(tipSection, '').trim();
|
|
2090
|
+
if (narrative && recommendations) {
|
|
2091
|
+
return cleanParsedAiParts(narrative, recommendations);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// Guest-style prose with **Zwischenüberschriften** only (common Ollama output)
|
|
2096
|
+
if (/\*\*[^*\n]{2,80}\*\*/.test(t)) {
|
|
2097
|
+
let body = t.replace(/^\s*Hier ist (?:der|die)\s+[^.\n]*[.:]\s*/i, '').trim();
|
|
2098
|
+
if (body.length < 20) {
|
|
2099
|
+
body = t.trim();
|
|
2100
|
+
}
|
|
2101
|
+
if (body.length > 25) {
|
|
2102
|
+
return cleanParsedAiParts(body, fallbackRec);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// Single block: use as narrative so export is not empty
|
|
2107
|
+
if (t.length > 25) {
|
|
2108
|
+
return cleanParsedAiParts(t.trim(), fallbackRec);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
return { narrative: '', recommendations: '' };
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
module.exports = AiEnhancer;
|