diff --git a/server.js b/server.js index fa104a1..e780ece 100644 --- a/server.js +++ b/server.js @@ -495,47 +495,54 @@ The user's local date/time is: ${todayStr} (today is ${weekdayStr}). We have the following existing contacts in our CRM: ${JSON.stringify(contactsList.map(c => ({ id: c.id, name: c.name })))} -Perform the following tasks: -1. Contact Matching: - - Match the input text to one of the existing contacts if the user is talking about or to them. +Perform the following tasks (CRITICAL PRIORITY: If the user is asking a question to retrieve, look up, or ask about information in the CRM, you MUST set "type": "query". This takes absolute precedence over creating contact notes or journal logs!): + +1. Query / Information Retrieval (HIGHEST PRIORITY): + - If the user's input is a question about a contact's details, birthdate, notes, hobbys, phone, email, or address (e.g. "Wann hat Steffi Geburtstag?", "Was sind Steffis Hobbys?", "Wer liest gerne Krimis?", "Wo wohnt Aaron?"), you MUST set "type" to "query". + - Match the contact mentioned in the question to an existing contact in the CRM from the provided contactsList if possible (set query.target_contact_id to their ID and query.target_contact_name to their complete name). If it's a general question or matches no specific contact (e.g. "Wer näht gerne?"), set query.target_contact_id = null and query.target_contact_name = null. + - Set query.enabled = true and set query.question to the user's original question text. + - For a query, keep contact/note/journal/reminders disabled or null (to avoid writing duplicate or false logs). + +2. Contact Matching: + - Match the input text to one of the existing contacts if the user is talking about or to them (and it is NOT a query question). - Look for full names, first names, nicknames, or close matches. - If matched, set "contact" to: { "is_new": false, "id": , "name": "", "first_name": "", "last_name": "" } (Note: extract the first_name and last_name of the contact if known, or leave them empty/null). -2. New Contact Auto-Creation: +3. New Contact Auto-Creation: - If NO existing contact matches, and the user mentions a new name with BOTH a First and a Last name (e.g., "Tobias Misch"), OR mentions a single name with explicit contact details (like phone, email, address, or birthdate), set "contact" to: { "is_new": true, "id": null, "name": "", "first_name": "", "last_name": "", "birthdate": "", "phone": "", "email": "", "address": "" } - IMPORTANT: Only create a new contact (is_new: true) if BOTH first and last names are detected, OR if there is a single name accompanied by explicit details (phone, email, address, or birthdate). Do not auto-create contacts for a single first name with no other details (to avoid clutter). -3. Contact Details Extraction & Enrichment (for BOTH new and existing contacts): +4. Contact Details Extraction & Enrichment (for BOTH new and existing contacts): - Extract any of the following if mentioned: - birthdate: Birthday formatted as "YYYY-MM-DD" or "0000-MM-DD" if the year is unknown/unstated (e.g. "Geburtstag am 14. September" -> "0000-09-14"). - phone: Cell phone or phone number. - email: Email address. - address: Address / place of living. -4. Note Extraction (Clean Contact Note): - - If the text is about a contact, extract/write a clean, contextual, grammatically correct German note summarizing the information or interaction. +5. Note Extraction (Clean Contact Note): + - If the text is about a contact (and is NOT a query question), extract/write a clean, contextual, grammatically correct German note summarizing the information or interaction. - CRITICAL: Keep note fields clean! The note body MUST NOT contain raw commands or instructions (e.g., "Erinnere mich an...", "Trage Geburtstag ein"), nor should it contain raw contact details (phone, email, address, birthdate) that are already captured in the structured fields. The note should be a short, premium German summary of the actual interaction or context (e.g., "Heute getroffen und ein Bier getrunken." or "Ist umgezogen und hat eine neue Telefonnummer."). - Set note.enabled = true, note.isFavorited = true if the input feels highly important. -5. Reminder Extraction: +6. Reminder Extraction: - Extract reminders if the user wants to be reminded of something (e.g., "erinnere mich am Freitag nachzufragen", "erinnere mich morgen anzurufen"). - Calculate the absolute date based on relative expressions and today's date (${todayStr}). - Format: { "enabled": true, "title": "", "description": "", "initial_date": "YYYY-MM-DD", "frequency_type": "one_time", "frequency_number": 1 } - CRITICAL: Skip creating reminders for birthdays, since the native birthdate field in Monica CRM automatically triggers birthday reminders. -6. Journal / Tagebuch: - - If the text has a diary/journal character (e.g. "heute war...", "ich fühle...", or general reflection), set journal.enabled = true, write a suitable German journal title, and set the post as the text. +7. Journal / Tagebuch: + - If the text has a diary/journal character (e.g. "heute war...", "ich fühle...", or general reflection) and is NOT a query question, set journal.enabled = true, write a suitable German journal title, and set the post as the text. -7. Mode override: +8. Mode override: - If mode is "journal", force type to "journal", set journal.enabled = true, and contact/note to null/disabled. Output a single valid JSON object following this EXACT schema. Do not output any markdown formatting, backticks, or explanation. Only the raw JSON. JSON Schema: { - "type": "contact" | "journal" | "mixed", + "type": "contact" | "journal" | "mixed" | "query", "summary": "Short German summary of the text (max 150 chars)", "contact": { "is_new": boolean, @@ -568,6 +575,12 @@ JSON Schema: "frequency_number": 1 } ], + "query": { + "enabled": boolean, + "target_contact_id": number | null, + "target_contact_name": "string" | null, + "question": "string" | null + } | null, "confidence": "low" | "medium" | "high" } `; @@ -647,7 +660,11 @@ export function analyzeText(text, mode = "auto", contactsList = []) { const normalized = text.replace(/\s+/g, " ").trim(); const contacts = mode === "journal" ? [] : inferContacts(normalized, contactsList); const reminder = inferReminder(normalized); - const type = mode === "auto" ? inferType(normalized, contacts, reminder) : mode; + + // Basic query regex detection for fallback robust classification + const isQuery = /^(wann|wer|was|wie|wo|warum|welche|welcher|welches|haben|hat|wohnt|macht|hobbys|geburtstag)\b/i.test(normalized) || normalized.endsWith("?"); + const type = isQuery ? "query" : (mode === "auto" ? inferType(normalized, contacts, reminder) : mode); + const summary = summarize(normalized); const today = new Date(); @@ -658,7 +675,7 @@ export function analyzeText(text, mode = "auto", contactsList = []) { contacts, contact: contacts[0] || null, note: { - enabled: type !== "journal", + enabled: type !== "journal" && type !== "query", body: buildContactNote(normalized, reminder), isFavorited: /wichtig|merken|nicht vergessen|unbedingt/i.test(normalized), }, @@ -668,7 +685,13 @@ export function analyzeText(text, mode = "auto", contactsList = []) { post: normalized, }, reminders: reminder ? [reminder] : [], - confidence: contacts.length || reminder ? "medium" : "low", + query: { + enabled: type === "query", + target_contact_id: contacts[0]?.id || null, + target_contact_name: contacts[0]?.name || null, + question: text + }, + confidence: contacts.length || reminder || isQuery ? "medium" : "low", }; } @@ -884,6 +907,169 @@ function demoContacts(query) { if (!query) return contacts; return contacts.filter((contact) => contact.name.toLowerCase().includes(query.toLowerCase())); } +export async function fetchContactContext(contactId) { + const id = Number(contactId); + try { + // 1. Fetch contact base details + const contactPayload = await monicaFetch(`/api/contacts/${id}`); + const contact = contactPayload.data || {}; + + // 2. Fetch contact fields (emails, phones, etc.) + let fields = []; + try { + const fieldsPayload = await monicaFetch(`/api/contacts/${id}/contactfields`); + fields = Array.isArray(fieldsPayload.data) ? fieldsPayload.data : []; + } catch (e) { + console.log(`[RAG] Could not load fields for contact ${id}:`, e.message); + } + + // 3. Fetch addresses + let addresses = []; + try { + const addressPayload = await monicaFetch(`/api/contacts/${id}/addresses`); + addresses = Array.isArray(addressPayload.data) ? addressPayload.data : []; + } catch (e) { + console.log(`[RAG] Could not load addresses for contact ${id}:`, e.message); + } + + // 4. Fetch notes + let notes = []; + try { + const notesPayload = await monicaFetch(`/api/contacts/${id}/notes`); + notes = Array.isArray(notesPayload.data) ? notesPayload.data : []; + } catch (e) { + console.log(`[RAG] Could not load notes for contact ${id}:`, e.message); + } + + const name = contact.complete_name || [contact.first_name, contact.last_name].filter(Boolean).join(" "); + + return { + type: "single_contact", + contactId: id, + name, + first_name: contact.first_name || "", + last_name: contact.last_name || "", + description: contact.description || "", + birthdate: contact.information?.birthdate?.date || contact.birthdate || "Unbekannt", + is_birthdate_known: contact.information?.birthdate?.is_year_unknown === false || !!contact.birthdate, + emails: fields.filter(f => f.contact_field_type_id === 1 || f.contact_field_type?.name?.toLowerCase().includes("email")).map(f => f.data), + phones: fields.filter(f => f.contact_field_type_id === 3 || f.contact_field_type?.name?.toLowerCase().includes("phone") || f.contact_field_type?.name?.toLowerCase().includes("telefon")).map(f => f.data), + addresses: addresses.map(addr => [addr.street, addr.postal_code, addr.city].filter(Boolean).join(", ")), + notes: notes.map(n => ({ + body: n.body, + created_at: n.created_at + })) + }; + } catch (error) { + console.error(`[RAG] Error fetching contact details for contact ${id}:`, error.message); + throw new Error(`CRM-Daten für Kontakt ID ${id} konnten nicht geladen werden.`); + } +} + +export async function fetchGlobalContext() { + try { + // 1. Fetch all contacts + const contactsPayload = await monicaFetch("/api/contacts?limit=150"); + const contacts = Array.isArray(contactsPayload.data) ? contactsPayload.data : []; + + // 2. Fetch all notes + const notesPayload = await monicaFetch("/api/notes?limit=150"); + const notes = Array.isArray(notesPayload.data) ? notesPayload.data : []; + + return { + type: "global_crm", + contacts: contacts.map(c => ({ + id: c.id, + name: c.complete_name || [c.first_name, c.last_name].filter(Boolean).join(" "), + description: c.description || "", + birthdate: c.information?.birthdate?.date || "Unbekannt" + })), + notes: notes.map(n => ({ + body: n.body, + created_at: n.created_at, + contact_id: n.contact?.id || n.contact_id, + contact_name: n.contact?.complete_name || [n.contact?.first_name, n.contact?.last_name].filter(Boolean).join(" ") || "Unbekannt" + })) + }; + } catch (error) { + console.error("[RAG] Error fetching global CRM context:", error.message); + throw new Error("Globale CRM-Daten konnten nicht geladen werden."); + } +} + +export async function answerQuestionWithGemini(question, context) { + const geminiKey = process.env.GEMINI_API_KEY; + if (!geminiKey) { + throw new Error("GEMINI_API_KEY is missing in env configuration."); + } + + const systemInstruction = ` +You are an intelligent, friendly personal assistant helping the user query their Monica CRM. +Answer the user's question about their contacts, birthdates, notes, or hobbys based ONLY on the provided Monica CRM Context. + +Rules: +1. Answer in German, be polite, premium, and concise. +2. Rely strictly on the facts provided in the CRM Context. Do not hallucinate or make up details. +3. If the information is not in the CRM Context, explain nicely that you could not find this information in the CRM notes or details. +4. Keep the answer brief and to the point. +5. In your answer, do not output technical details like "CRM Context", "JSON", "contact_id", or "API". Keep the response completely natural, as if you personally remembered the details. +6. Provide a short summary or reference of which note or details you used to answer (e.g. "Laut deinen Notizen..." or "Laut dem Geburtsdatum-Profil..."). + +CRM Context: +${JSON.stringify(context, null, 2)} +`; + + const modelsToTry = [ + "gemini-2.5-flash", + "gemini-2.0-flash", + "gemini-1.5-flash-latest", + "gemini-1.5-flash" + ]; + + let lastError = null; + for (const model of modelsToTry) { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${geminiKey}`; + try { + console.log(`[RAG] Answering query using model: ${model}`); + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + contents: [ + { + parts: [ + { + text: `${systemInstruction}\n\nUser Question:\n"${question}"` + } + ] + } + ] + }), + }); + + if (res.status === 404) { + console.warn(`[RAG] Model ${model} returned 404. Trying next model...`); + continue; + } + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Gemini API failed (${res.status}): ${errorText}`); + } + + const data = await res.json(); + const answer = data.candidates?.[0]?.content?.parts?.[0]?.text || ""; + return answer.trim(); + } catch (err) { + console.error(`[RAG] Error with model ${model}:`, err.message); + lastError = err; + } + } + + throw new Error(`All Gemini models failed for RAG answering. Last error: ${lastError ? lastError.message : "unknown"}`); +} export async function appendLocalRecord(record) { await fs.mkdir(dataDir, { recursive: true });