810 lines
33 KiB
PHP
810 lines
33 KiB
PHP
<?php
|
|
/**
|
|
* Fociz AI Co-Pilot Engine (Release v1.5)
|
|
* Beheert intelligente assistentie voor Brainstorming en Dagplanning.
|
|
*/
|
|
|
|
require_once __DIR__ . '/http.php';
|
|
require_once __DIR__ . '/settings.php';
|
|
|
|
/**
|
|
* STRICT ZERO-DATA LEAKAGE PRIVACY FIREWALL
|
|
* Garandeert dat NOOIT data uit Jira, Mail (Outlook) of Microsoft Teams
|
|
* doorgestuurd kan worden naar een externe AI provider (Gemini / OpenAI).
|
|
*
|
|
* @param array $messages Referentie naar de messages array die verzonden wordt
|
|
* @return array Aantal en type geactiveerde firewall filters
|
|
*/
|
|
function fociz_ai_enforce_privacy_firewall(array &$messages): array {
|
|
$detections = [
|
|
'jira_tickets' => 0,
|
|
'jira_urls' => 0,
|
|
'teams_links' => 0,
|
|
'email_addrs' => 0,
|
|
'mail_headers' => 0,
|
|
];
|
|
|
|
$patterns = [
|
|
// Jira ticket keys zoals PROJ-1234, ABC-99 (2 tot 10 hoofdletters gevolgd door streepje en cijfers)
|
|
'jira_tickets' => '/\b[A-Z]{2,10}-\d+\b/',
|
|
// Jira / Atlassian domeinen en URLs
|
|
'jira_urls' => '/https?:\/\/[a-zA-Z0-9-]+\.atlassian\.net[^\s]*/i',
|
|
// Teams URLs en links
|
|
'teams_links' => '/https?:\/\/teams\.microsoft\.com[^\s]*/i',
|
|
// E-mailadressen
|
|
'email_addrs' => '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/',
|
|
// E-mailheaders en Microsoft Outlook / Teams traces
|
|
'mail_headers' => '/\b(Subject:|Onderwerp:|From:|Van:|To:|Aan:|CC:|BCC:|FW:|Fwd:|RE:)\s*[^\r\n]+/i',
|
|
];
|
|
|
|
$replacements = [
|
|
'jira_tickets' => '[TICKET_AFGESCHERMD]',
|
|
'jira_urls' => '[JIRA_URL_AFGESCHERMD]',
|
|
'teams_links' => '[TEAMS_LINK_AFGESCHERMD]',
|
|
'email_addrs' => '[EMAIL_AFGESCHERMD]',
|
|
'mail_headers' => '[MAIL_HEADER_AFGESCHERMD]',
|
|
];
|
|
|
|
foreach ($messages as $idx => $msg) {
|
|
if (!isset($msg['content']) || !is_string($msg['content'])) {
|
|
continue;
|
|
}
|
|
|
|
$text = $msg['content'];
|
|
foreach ($patterns as $key => $pattern) {
|
|
$count = 0;
|
|
$text = preg_replace($pattern, $replacements[$key], $text, -1, $count);
|
|
if ($count > 0) {
|
|
$detections[$key] += $count;
|
|
}
|
|
}
|
|
$messages[$idx]['content'] = $text;
|
|
}
|
|
|
|
$totalBlocked = array_sum($detections);
|
|
if ($totalBlocked > 0) {
|
|
error_log(sprintf(
|
|
"[FOCIZ AI FIREWALL] Zero-Data Leakage actief: %d potentiële datalekken geblokkeerd en geanonimiseerd (%s)",
|
|
$totalBlocked,
|
|
json_encode($detections)
|
|
));
|
|
}
|
|
|
|
return $detections;
|
|
}
|
|
|
|
/**
|
|
* Voert een gestructureerde chat completion uit met Google Gemini Pro
|
|
*
|
|
* @param array $messages
|
|
* @param array $options
|
|
* @param array $aiSettings
|
|
* @return array
|
|
*/
|
|
function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings): array {
|
|
$apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
|
|
$model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gemini-2.0-flash';
|
|
$temperature = $options['temperature'] ?? 0.3;
|
|
$maxTokens = max(512, (int)($options['max_tokens'] ?? 2048));
|
|
$jsonMode = !empty($options['json_mode']);
|
|
|
|
if (empty($apiKey)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Geen Google Gemini API-sleutel ingevuld of geconfigureerd.',
|
|
];
|
|
}
|
|
|
|
// Google Generative Language API structuur opbouwen
|
|
$systemInstructions = [];
|
|
$contents = [];
|
|
|
|
foreach ($messages as $msg) {
|
|
$role = $msg['role'] ?? 'user';
|
|
$text = (string)($msg['content'] ?? '');
|
|
|
|
if ($role === 'system') {
|
|
$systemInstructions[] = ['text' => $text];
|
|
} else {
|
|
$contents[] = [
|
|
'role' => ($role === 'assistant') ? 'model' : 'user',
|
|
'parts' => [['text' => $text]],
|
|
];
|
|
}
|
|
}
|
|
|
|
if (empty($contents)) {
|
|
$contents[] = [
|
|
'role' => 'user',
|
|
'parts' => [['text' => 'Hallo']],
|
|
];
|
|
}
|
|
|
|
$payload = [
|
|
'contents' => $contents,
|
|
'generationConfig' => [
|
|
'temperature' => (float)$temperature,
|
|
'maxOutputTokens' => (int)$maxTokens,
|
|
],
|
|
];
|
|
|
|
if (!empty($systemInstructions)) {
|
|
$payload['system_instruction'] = [
|
|
'parts' => $systemInstructions,
|
|
];
|
|
}
|
|
|
|
if ($jsonMode) {
|
|
$payload['generationConfig']['responseMimeType'] = 'application/json';
|
|
}
|
|
|
|
$cleanModel = preg_replace('#^models/#', '', $model);
|
|
|
|
// Helper om tekst uit Gemini candidate te extraheren
|
|
$extractCandidateText = function(?array $candidate): string {
|
|
if (!$candidate || empty($candidate['content']['parts'])) {
|
|
return '';
|
|
}
|
|
$txt = '';
|
|
foreach ($candidate['content']['parts'] as $part) {
|
|
if (!empty($part['text']) && empty($part['thought'])) {
|
|
$txt .= $part['text'];
|
|
}
|
|
}
|
|
if ($txt === '' && !empty($candidate['content']['parts'][0]['text'])) {
|
|
$txt = $candidate['content']['parts'][0]['text'];
|
|
}
|
|
return trim($txt);
|
|
};
|
|
|
|
// Normaliseer modelnaam en stel geldige kandidatenlijst op
|
|
$candidateUrls = [];
|
|
|
|
// Helper om endpoints op te bouwen voor v1beta en v1
|
|
$addCandidatesFor = function(string $m) use (&$candidateUrls) {
|
|
$m = preg_replace('#^models/#', '', trim($m));
|
|
if ($m === '') return;
|
|
$v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent";
|
|
$v1 = "https://generativelanguage.googleapis.com/v1/models/{$m}:generateContent";
|
|
if (!in_array($v1beta, $candidateUrls, true)) {
|
|
$candidateUrls[] = $v1beta;
|
|
}
|
|
if (!in_array($v1, $candidateUrls, true)) {
|
|
$candidateUrls[] = $v1;
|
|
}
|
|
};
|
|
|
|
// Voeg het geconfigureerde model toe als eerste kandidaat (tenzij het een niet-bestaand/fictief model is)
|
|
if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) {
|
|
$addCandidatesFor($cleanModel);
|
|
}
|
|
|
|
// Voeg altijd de stabiele, snelle Google AI modellen toe als betrouwbare fallbacks/standaarden
|
|
$addCandidatesFor('gemini-2.0-flash');
|
|
$addCandidatesFor('gemini-1.5-flash');
|
|
$addCandidatesFor('gemini-1.5-pro');
|
|
$addCandidatesFor('gemini-2.5-flash');
|
|
|
|
$response = null;
|
|
$lastError = null;
|
|
$successfulModel = $cleanModel;
|
|
$finalText = '';
|
|
|
|
for ($i = 0; $i < count($candidateUrls); $i++) {
|
|
$baseUrl = $candidateUrls[$i];
|
|
$endpoint = $baseUrl . '?key=' . urlencode($apiKey);
|
|
$res = fociz_http_request($endpoint, [
|
|
'method' => 'POST',
|
|
'headers' => [
|
|
'Content-Type' => 'application/json',
|
|
'x-goog-api-key' => $apiKey,
|
|
],
|
|
'body' => $payload,
|
|
'timeout' => 20,
|
|
]);
|
|
|
|
if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) {
|
|
$extracted = $extractCandidateText($res['json']['candidates'][0]);
|
|
// Als er tekst is, of finishReason is geldig (bijv. STOP of MAX_TOKENS)
|
|
if ($extracted !== '' || !empty($res['json']['candidates'][0]['finishReason'])) {
|
|
$response = $res;
|
|
$finalText = ($extracted !== '') ? $extracted : 'OK';
|
|
if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) {
|
|
$successfulModel = $mName[1];
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
$msg = $res['json']['error']['message'] ?? ($res['error'] ?? '');
|
|
$lastError = $msg;
|
|
|
|
// Als Google in de foutmelding direct een nieuwer model aanbeveelt (bv. "use models/gemini-3.6-flash")
|
|
if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) {
|
|
$recModel = $recMatch[1];
|
|
$recUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$recModel}:generateContent";
|
|
if (!in_array($recUrl, $candidateUrls, true)) {
|
|
$candidateUrls[] = $recUrl;
|
|
}
|
|
}
|
|
|
|
// Als het model niet gevonden is, niet meer beschikbaar is of deprecated is, probeer de volgende kandidaat
|
|
if ($res['status'] === 404 || str_contains($msg, 'not found') || str_contains($msg, 'not supported') || str_contains($msg, 'no longer available') || str_contains($msg, 'deprecated')) {
|
|
continue;
|
|
}
|
|
|
|
// Bij autorisatiefouten (zoals ongeldige API key 400/403) direct stoppen
|
|
$response = $res;
|
|
break;
|
|
}
|
|
|
|
// Als alle directe kandidaten een 404 gaven, probeer dynamisch via ListModels een passend gemini model te vinden
|
|
if (!$response && ($lastError && (str_contains($lastError, 'not found') || str_contains($lastError, 'not supported')))) {
|
|
$listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=" . urlencode($apiKey);
|
|
$listRes = fociz_http_request($listUrl, [
|
|
'method' => 'GET',
|
|
'headers' => ['x-goog-api-key' => $apiKey],
|
|
'timeout' => 10,
|
|
]);
|
|
|
|
if ($listRes['status'] === 200 && !empty($listRes['json']['models'])) {
|
|
$availModels = [];
|
|
foreach ($listRes['json']['models'] as $mItem) {
|
|
$mMethods = $mItem['supportedGenerationMethods'] ?? [];
|
|
if (in_array('generateContent', $mMethods, true)) {
|
|
$mClean = preg_replace('#^models/#', '', $mItem['name'] ?? '');
|
|
if ($mClean) $availModels[] = $mClean;
|
|
}
|
|
}
|
|
|
|
// Zoek een passend gemini model
|
|
$fallbackModel = null;
|
|
foreach ($availModels as $am) {
|
|
if (str_contains($am, 'flash')) {
|
|
$fallbackModel = $am;
|
|
break;
|
|
}
|
|
}
|
|
if (!$fallbackModel) {
|
|
foreach ($availModels as $am) {
|
|
if (str_contains($am, 'gemini')) {
|
|
$fallbackModel = $am;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($fallbackModel) {
|
|
$dynEndpoint = "https://generativelanguage.googleapis.com/v1beta/models/{$fallbackModel}:generateContent?key=" . urlencode($apiKey);
|
|
$dynRes = fociz_http_request($dynEndpoint, [
|
|
'method' => 'POST',
|
|
'headers' => [
|
|
'Content-Type' => 'application/json',
|
|
'x-goog-api-key' => $apiKey,
|
|
],
|
|
'body' => $payload,
|
|
'timeout' => 20,
|
|
]);
|
|
|
|
if ($dynRes['status'] === 200 && !empty($dynRes['json']['candidates'][0])) {
|
|
$extracted = $extractCandidateText($dynRes['json']['candidates'][0]);
|
|
if ($extracted !== '' || !empty($dynRes['json']['candidates'][0]['finishReason'])) {
|
|
$response = $dynRes;
|
|
$finalText = ($extracted !== '') ? $extracted : 'OK';
|
|
$successfulModel = $fallbackModel;
|
|
}
|
|
} else {
|
|
$response = $dynRes;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$response || $response['status'] !== 200 || $finalText === '') {
|
|
$errMsg = null;
|
|
if (!empty($response['json']['error']['message'])) {
|
|
$errMsg = $response['json']['error']['message'];
|
|
} elseif (!empty($response['error'])) {
|
|
$errMsg = $response['error'];
|
|
} elseif (!empty($response['body'])) {
|
|
$errMsg = "Gemini fout (HTTP {$response['status']}): " . strip_tags(substr((string)$response['body'], 0, 200));
|
|
} else {
|
|
$errMsg = $lastError ?: "Gemini API aanroep mislukt";
|
|
}
|
|
error_log("fociz_ai_chat_gemini fout: {$errMsg}");
|
|
return [
|
|
'success' => false,
|
|
'error' => $errMsg,
|
|
];
|
|
}
|
|
|
|
$rawContent = $finalText;
|
|
|
|
// Indien Gemini het als markdown code block wrapped heeft (bv. ```json ... ```), schoon dit op
|
|
if (preg_match('/^```(?:json)?\s*(.*?)\s*```$/s', $rawContent, $m)) {
|
|
$cleanContent = trim($m[1]);
|
|
} else {
|
|
$cleanContent = $rawContent;
|
|
}
|
|
|
|
$parsedJson = null;
|
|
if ($jsonMode) {
|
|
$parsedJson = json_decode($cleanContent, true);
|
|
if ($parsedJson === null && json_last_error() !== JSON_ERROR_NONE) {
|
|
// Poging 2: json decode met raw
|
|
$parsedJson = json_decode($rawContent, true);
|
|
}
|
|
// Poging 3: extraheer JSON substring {...} of [...] indien AI conversatietekst heeft toegevoegd
|
|
if ($parsedJson === null) {
|
|
if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $cleanContent, $matchJson)) {
|
|
$parsedJson = json_decode($matchJson[0], true);
|
|
} elseif (preg_match('/\[(?:[^\[\]]|(?R))*\]/s', $cleanContent, $matchJson)) {
|
|
$parsedJson = json_decode($matchJson[0], true);
|
|
}
|
|
}
|
|
}
|
|
|
|
$promptTokens = (int)($response['json']['usageMetadata']['promptTokenCount'] ?? 0);
|
|
$completionTokens = (int)($response['json']['usageMetadata']['candidatesTokenCount'] ?? 0);
|
|
if ($promptTokens === 0 && $completionTokens === 0) {
|
|
// Schatting obv lengte indien niet geretourneerd
|
|
$promptTokens = max(1, (int)(strlen(json_encode($payload)) / 4));
|
|
$completionTokens = max(1, (int)(strlen($cleanContent) / 4));
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'content' => $cleanContent,
|
|
'json' => $parsedJson,
|
|
'model' => $successfulModel,
|
|
'provider' => 'gemini',
|
|
'prompt_tokens' => $promptTokens,
|
|
'completion_tokens' => $completionTokens,
|
|
'total_tokens' => ($promptTokens + $completionTokens),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Voert een gestructureerde chat completion uit met OpenAI
|
|
*
|
|
* @param array $messages
|
|
* @param array $options
|
|
* @param array $aiSettings
|
|
* @return array
|
|
*/
|
|
function fociz_ai_chat_openai(array $messages, array $options, array $aiSettings): array {
|
|
$apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
|
|
$model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gpt-4o-mini';
|
|
$temperature = $options['temperature'] ?? 0.3;
|
|
$maxTokens = $options['max_tokens'] ?? 1500;
|
|
$jsonMode = !empty($options['json_mode']);
|
|
|
|
if (empty($apiKey)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Geen OpenAI API-sleutel ingevuld of geconfigureerd.',
|
|
];
|
|
}
|
|
|
|
$payload = [
|
|
'model' => $model,
|
|
'messages' => $messages,
|
|
'temperature' => $temperature,
|
|
'max_tokens' => $maxTokens,
|
|
];
|
|
|
|
if ($jsonMode) {
|
|
$payload['response_format'] = ['type' => 'json_object'];
|
|
}
|
|
|
|
$response = fociz_http_request('https://api.openai.com/v1/chat/completions', [
|
|
'method' => 'POST',
|
|
'headers' => [
|
|
'Authorization' => "Bearer {$apiKey}",
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
'body' => $payload,
|
|
'timeout' => 20,
|
|
]);
|
|
|
|
if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) {
|
|
$errMsg = null;
|
|
if (!empty($response['json']['error']['message'])) {
|
|
$errMsg = $response['json']['error']['message'];
|
|
} elseif (!empty($response['error'])) {
|
|
$errMsg = $response['error'];
|
|
} elseif (!empty($response['body'])) {
|
|
$errMsg = "OpenAI fout (HTTP {$response['status']}): " . strip_tags(substr((string)$response['body'], 0, 200));
|
|
} else {
|
|
$errMsg = "OpenAI API aanroep mislukt (HTTP {$response['status']})";
|
|
}
|
|
error_log("fociz_ai_chat_openai fout: {$errMsg}");
|
|
return [
|
|
'success' => false,
|
|
'error' => $errMsg,
|
|
];
|
|
}
|
|
|
|
$rawContent = trim($response['json']['choices'][0]['message']['content']);
|
|
$parsedJson = null;
|
|
|
|
if ($jsonMode) {
|
|
$parsedJson = json_decode($rawContent, true);
|
|
}
|
|
|
|
$promptTokens = (int)($response['json']['usage']['prompt_tokens'] ?? 0);
|
|
$completionTokens = (int)($response['json']['usage']['completion_tokens'] ?? 0);
|
|
if ($promptTokens === 0 && $completionTokens === 0) {
|
|
$promptTokens = max(1, (int)(strlen(json_encode($payload)) / 4));
|
|
$completionTokens = max(1, (int)(strlen($rawContent) / 4));
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'content' => $rawContent,
|
|
'json' => $parsedJson,
|
|
'model' => $response['json']['model'] ?? $model,
|
|
'provider' => 'openai',
|
|
'prompt_tokens' => $promptTokens,
|
|
'completion_tokens' => $completionTokens,
|
|
'total_tokens' => ($promptTokens + $completionTokens),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Hoofdfunctie: Voert een gestructureerde chat completion uit
|
|
* Routeert automatisch naar Google Gemini Pro of OpenAI en dwingt ALTIJD
|
|
* de Zero-Data Leakage privacy firewall af.
|
|
*
|
|
* @param array $messages [['role' => 'system'|'user'|'assistant', 'content' => string]]
|
|
* @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int, 'user_id' => ?int]
|
|
* @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string, 'provider' => ?string]
|
|
*/
|
|
function fociz_ai_chat(array $messages, array $options = []): array {
|
|
// 1. ABSOLUTE ZERO-DATA LEAKAGE PRIVACY CONTROLE:
|
|
// Filter ALLE Jira ticket keys, Atlassian URLs, Teams links en Mail adressen/headers
|
|
fociz_ai_enforce_privacy_firewall($messages);
|
|
|
|
$userId = $options['user_id'] ?? null;
|
|
$aiSettings = get_ai_settings($userId);
|
|
|
|
if (empty($aiSettings['ai_api_key'])) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Geen AI API-sleutel geconfigureerd. Stel Google Gemini Pro of OpenAI in via Systeembeheer of je profiel.',
|
|
];
|
|
}
|
|
|
|
$provider = strtolower($aiSettings['ai_provider'] ?? 'gemini');
|
|
$model = strtolower($aiSettings['ai_model'] ?? '');
|
|
|
|
// Routering: als provider gemini is of model gemini bevat, stuur naar Google Gemini
|
|
if ($provider === 'gemini' || strpos($model, 'gemini') !== false) {
|
|
$res = fociz_ai_chat_gemini($messages, $options, $aiSettings);
|
|
} else {
|
|
$res = fociz_ai_chat_openai($messages, $options, $aiSettings);
|
|
}
|
|
|
|
// Verbruiksstatistieken registreren bij geslaagde aanroep
|
|
if (!empty($res['success'])) {
|
|
$usedProvider = $res['provider'] ?? $provider;
|
|
$usedModel = $res['model'] ?? $model;
|
|
$pTokens = (int)($res['prompt_tokens'] ?? 0);
|
|
$cTokens = (int)($res['completion_tokens'] ?? 0);
|
|
record_ai_usage($usedProvider, $usedModel, $pTokens, $cTokens, $userId);
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
|
|
/**
|
|
* Werkt een brainstorm idee uit tot 3-5 concrete, behapbare items
|
|
* voor een Kanban Bord, Sprint/Tijdlijn of Dagplanner.
|
|
*
|
|
* @param string $ideaContent
|
|
* @param string $sessionTitle
|
|
* @param string $theme
|
|
* @param int|null $userId
|
|
* @return array ['success' => bool, 'items' => array, 'error' => ?string]
|
|
*/
|
|
function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '', string $theme = '', ?int $userId = null): array {
|
|
$systemPrompt = "Je bent een senior Agile/Scrum product specialist en technisch ontwerper.\n"
|
|
. "Je taak is om een brainstorm idee of concept om te zetten in 3 tot 5 concrete, behapbare en direct uitvoerbare taken voor een agile team.\n"
|
|
. "Elk item moet een heldere taak zijn die binnen één dag of sprintblok voltooid kan worden.\n\n"
|
|
. "Formateer de uitvoer als een JSON-object met exact deze structuur:\n"
|
|
. "{\n"
|
|
. " \"summary\": \"Korte samenvatting van de aanpak (1 zin)\",\n"
|
|
. " \"items\": [\n"
|
|
. " {\n"
|
|
. " \"title\": \"Actiegerichte titel (bijv: API endpoint ontwerpen voor authenticatie)\",\n"
|
|
. " \"description\": \"Korte toelichting en 2 concrete acceptatiecriteria\",\n"
|
|
. " \"estimated_minutes\": 45,\n"
|
|
. " \"priority\": \"P1\",\n"
|
|
. " \"color\": \"indigo\",\n"
|
|
. " \"category\": \"Backend\"\n"
|
|
. " }\n"
|
|
. " ]\n"
|
|
. "}\n\n"
|
|
. "Regels:\n"
|
|
. "- Prioriteit moet 'P1' (hoog/focus), 'P2' (medium) of 'P3' (laag) zijn.\n"
|
|
. "- Kleur moet gekozen worden uit: 'indigo', 'blue', 'emerald', 'amber', 'rose', 'purple'.\n"
|
|
. "- Duur in minuten: 15, 30, 45, 60, 90 of 120.\n"
|
|
. "- Categorie: 'Frontend', 'Backend', 'Design', 'Onderzoek', of 'Testing'.\n"
|
|
. "- Retourneer UITSCHLUITEND geldige JSON.";
|
|
|
|
$userPrompt = "Brainstorm Sessie: " . ($sessionTitle ?: 'Algemeen') . "\n"
|
|
. "Thema: " . ($theme ?: 'Productontwikkeling') . "\n\n"
|
|
. "Uit te werken idee:\n\"" . $ideaContent . "\"\n\n"
|
|
. "Splits dit idee op in 3 tot 5 concrete items.";
|
|
|
|
$aiRes = fociz_ai_chat([
|
|
['role' => 'system', 'content' => $systemPrompt],
|
|
['role' => 'user', 'content' => $userPrompt],
|
|
], [
|
|
'json_mode' => true,
|
|
'temperature' => 0.4,
|
|
'max_tokens' => 2000,
|
|
'user_id' => $userId,
|
|
]);
|
|
|
|
if (!$aiRes['success']) {
|
|
error_log("[fociz_ai_breakdown_idea] AI request failed: " . ($aiRes['error'] ?? 'Unknown error'));
|
|
return [
|
|
'success' => false,
|
|
'error' => $aiRes['error'] ?? 'Kon het idee niet ontleden in taken via AI.',
|
|
];
|
|
}
|
|
|
|
$parsed = $aiRes['json'] ?? null;
|
|
$items = [];
|
|
$summary = '';
|
|
|
|
if (is_array($parsed)) {
|
|
if (!empty($parsed['items']) && is_array($parsed['items'])) {
|
|
$items = $parsed['items'];
|
|
} elseif (!empty($parsed['tasks']) && is_array($parsed['tasks'])) {
|
|
$items = $parsed['tasks'];
|
|
} elseif (!empty($parsed['taken']) && is_array($parsed['taken'])) {
|
|
$items = $parsed['taken'];
|
|
} elseif (!empty($parsed['cards']) && is_array($parsed['cards'])) {
|
|
$items = $parsed['cards'];
|
|
} elseif (array_is_list($parsed)) {
|
|
$items = $parsed;
|
|
}
|
|
$summary = (string)($parsed['summary'] ?? '');
|
|
}
|
|
|
|
// Als JSON parsing toch leeg was, probeer regex fallback op ruwe content
|
|
if (empty($items) && !empty($aiRes['content'])) {
|
|
if (preg_match('/\[\s*\{.*\}\s*\]/s', $aiRes['content'], $mArr)) {
|
|
$decodedArr = json_decode($mArr[0], true);
|
|
if (is_array($decodedArr)) {
|
|
$items = $decodedArr;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($items)) {
|
|
error_log("[fociz_ai_breakdown_idea] Geen geldige items gevonden in response: " . substr((string)($aiRes['content'] ?? ''), 0, 500));
|
|
return [
|
|
'success' => false,
|
|
'error' => 'De AI gaf een antwoord maar kon de taken niet structureren. Probeer het nogmaals.',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'summary' => $summary ?: 'Dit idee is opgesplitst in behapbare deeltaken.',
|
|
'items' => $items,
|
|
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Categoriseert sticky notes automatisch in:
|
|
* - 'Ideeën'
|
|
* - 'Knelpunten'
|
|
* - 'Oplossingen'
|
|
* - 'Quick Wins'
|
|
*/
|
|
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards, ?int $userId = null): array {
|
|
if (empty($cards)) {
|
|
return ['success' => true, 'categorized' => []];
|
|
}
|
|
|
|
$cardItems = [];
|
|
foreach ($cards as $c) {
|
|
$cardItems[] = [
|
|
'id' => $c['id'],
|
|
'content' => $c['content'],
|
|
'category' => $c['category'] ?? 'Ideeën',
|
|
];
|
|
}
|
|
|
|
$systemPrompt = "Je bent een deskundige agile facilitator en brainstorm co-pilot voor software- en productontwikkeling.\n"
|
|
. "Je ontvangt een lijst met brainstorm kaartjes voor de sessie: '{$sessionTitle}' (Thema: '{$theme}').\n"
|
|
. "Je taak is om elk kaartje in te delen in exact één van deze 4 categorieën:\n"
|
|
. "1. 'Ideeën' (nieuwe concepten, functies, wensen)\n"
|
|
. "2. 'Knelpunten' (problemen, blokkades, risico's, frustraties)\n"
|
|
. "3. 'Oplossingen' (concrete antwoorden op knelpunten, architectuurkeuzes)\n"
|
|
. "4. 'Quick Wins' (kleine, makkelijk uitvoerbare verbeteringen met hoge impact)\n\n"
|
|
. "Retourneer uitsluitend een JSON-object met het veld 'categorized' dat een array is van objecten met 'id' en 'category'.";
|
|
|
|
$userPrompt = "Hier zijn de kaartjes:\n" . json_encode($cardItems, JSON_UNESCAPED_UNICODE);
|
|
|
|
$aiRes = fociz_ai_chat([
|
|
['role' => 'system', 'content' => $systemPrompt],
|
|
['role' => 'user', 'content' => $userPrompt],
|
|
], ['json_mode' => true, 'user_id' => $userId]);
|
|
|
|
if (!$aiRes['success'] || empty($aiRes['json']['categorized'])) {
|
|
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon kaartjes niet categoriseren'];
|
|
}
|
|
|
|
return ['success' => true, 'categorized' => $aiRes['json']['categorized']];
|
|
}
|
|
|
|
/**
|
|
* Genereert nieuwe relevante brainstorm ideeën voor een sessie
|
|
*/
|
|
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4, ?int $userId = null): array {
|
|
$systemPrompt = "Je bent een innovatieve productstrateeg en brainstorm co-pilot.\n"
|
|
. "Genereer {$count} creatieve, scherpe en direct toepasbare ideeën voor een brainstormsessie getiteld: '{$sessionTitle}' (Thema: '{$theme}').\n"
|
|
. "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een gele post-it staan).\n"
|
|
. "Retourneer uitsluitend een JSON-object met het veld 'ideas' dat een array van strings is.";
|
|
|
|
$aiRes = fociz_ai_chat([
|
|
['role' => 'system', 'content' => $systemPrompt],
|
|
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën."],
|
|
], ['json_mode' => true, 'user_id' => $userId]);
|
|
|
|
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
|
|
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren'];
|
|
}
|
|
|
|
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
|
|
}
|
|
|
|
/**
|
|
* Zet brainstorm kaartjes om in een scherp Roadmap Item
|
|
*/
|
|
function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '', ?int $userId = null): array {
|
|
$systemPrompt = "Je bent een senior product manager. Zet onderstaand brainstorm idee om in een helder, professioneel Roadmap Item.\n"
|
|
. "Retourneer een JSON-object met:\n"
|
|
. "- 'title': Een beknopte, actiegerichte titel (max 8 woorden)\n"
|
|
. "- 'description': Een duidelijke beschrijving van wat er gebouwd moet worden inclusief gebruikerswaarde en 2-3 acceptatiecriteria\n"
|
|
. "- 'priority': 'HIGH', 'MEDIUM' of 'LOW'\n"
|
|
. "- 'type': 'FEATURE', 'IMPROVEMENT' of 'TECH_DEBT'\n"
|
|
. "- 'quarter': 'Q3' of 'Q4'";
|
|
|
|
$aiRes = fociz_ai_chat([
|
|
['role' => 'system', 'content' => $systemPrompt],
|
|
['role' => 'user', 'content' => "Sessie: {$sessionTitle}\nIdee:\n{$content}"],
|
|
], ['json_mode' => true, 'user_id' => $userId]);
|
|
|
|
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
|
|
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon roadmap item niet formuleren'];
|
|
}
|
|
|
|
return ['success' => true, 'item' => $aiRes['json']];
|
|
}
|
|
|
|
/**
|
|
* Berekent een slimme dagindeling voor open taken rondom geanonimiseerde bezette tijdblokken.
|
|
* ZERO-DATA LEAKAGE: Bevat NOOIT meetingtitels, inhoud of e-mailadressen!
|
|
*/
|
|
function fociz_ai_smart_schedule(array $tasks, array $busyBlocks, array $workdaySettings, ?int $userId = null): array {
|
|
$startH = (int)explode(':', $workdaySettings['workday_start'] ?? '08:30')[0];
|
|
$endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:00')[0];
|
|
$lunchStart = $workdaySettings['lunch_start'] ?? '12:30';
|
|
$lunchEnd = $workdaySettings['lunch_end'] ?? '13:00';
|
|
|
|
// Anonimiseer taken: stuur alleen ID, titel (gefilterd door firewall), prioriteit en duur
|
|
$sanitizedTasks = array_map(fn($t) => [
|
|
'id' => (int)$t['id'],
|
|
'title' => (string)$t['title'],
|
|
'priority' => $t['priority'] ?? 'P2',
|
|
'duration_minutes' => (int)($t['duration_minutes'] ?? 30),
|
|
], $tasks);
|
|
|
|
// Anonimiseer bezette blokken: GEEN titels of deelnemers, enkel tijdstippen!
|
|
$sanitizedBusy = array_map(fn($b) => [
|
|
'start' => $b['start'] ?? '09:00',
|
|
'end' => $b['end'] ?? '10:00',
|
|
], $busyBlocks);
|
|
|
|
$systemPrompt = "Je bent een productiviteits- en time-blocking assistent.\n"
|
|
. "Maak een optimale dagplanning door openstaande taken in te plannen tussen de werkdaguren ({$workdaySettings['workday_start']} tot {$workdaySettings['workday_end']}).\n"
|
|
. "Regels:\n"
|
|
. "1. Plan GEEN taken tijdens bezette blokken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n"
|
|
. "2. Geef prioriteit aan P1 (brandend/focus) vroeg op de dag, daarna P2 en P3.\n"
|
|
. "3. Zorg voor realistische tijdsblokken (15, 30, 45 of 60 minuten) met format 'HH:mm'.\n"
|
|
. "Retourneer een JSON-object met het veld 'scheduled' als array van objecten: {'id': int, 'time_block_start': 'HH:mm', 'duration_minutes': int}.";
|
|
|
|
$userPrompt = "Bezette tijdsintervallen (agenda):\n" . json_encode($sanitizedBusy, JSON_UNESCAPED_UNICODE) . "\n\n"
|
|
. "In te plannen taken:\n" . json_encode($sanitizedTasks, JSON_UNESCAPED_UNICODE);
|
|
|
|
$aiRes = fociz_ai_chat([
|
|
['role' => 'system', 'content' => $systemPrompt],
|
|
['role' => 'user', 'content' => $userPrompt],
|
|
], ['json_mode' => true, 'user_id' => $userId]);
|
|
|
|
if (!$aiRes['success'] || empty($aiRes['json']['scheduled'])) {
|
|
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen slimme indeling berekenen'];
|
|
}
|
|
|
|
return ['success' => true, 'scheduled' => $aiRes['json']['scheduled']];
|
|
}
|
|
|
|
/**
|
|
* Genereert een persoonlijke AI dagbriefing op basis van interne Fociz taken.
|
|
* ZERO-DATA LEAKAGE: Absoluut GEEN Jira, Mail (Outlook) of Teams data!
|
|
*
|
|
* @param string $userName Naam van de gebruiker
|
|
* @param string $date Datum (Y-m-d)
|
|
* @param array $tasks Openstaande interne taken van vandaag
|
|
* @param array $busyBlocks Geanonimiseerde bezette tijdsblokken (start/end)
|
|
* @param array $unfinishedYest Gisteren niet voltooide taken
|
|
* @param int|null $userId
|
|
* @return array ['success' => bool, 'greeting' => string, 'focus_task' => string, 'tip' => string, 'warnings' => array]
|
|
*/
|
|
function fociz_ai_daily_briefing(
|
|
string $userName,
|
|
string $date,
|
|
array $tasks,
|
|
array $busyBlocks = [],
|
|
array $unfinishedYest = [],
|
|
?int $userId = null
|
|
): array {
|
|
$dutchDays = ['', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'];
|
|
$dayName = $dutchDays[(int)date('N', strtotime($date))] ?? 'vandaag';
|
|
$dateLabel = $dayName . ' ' . date('j', strtotime($date)) . ' ' . date('F', strtotime($date));
|
|
|
|
// Alleen pure Fociz taken samenvatten
|
|
$taskSummary = array_map(fn($t) => [
|
|
'title' => $t['title'],
|
|
'priority' => $t['priority'] ?? 'P2',
|
|
'time' => $t['time_block_start'] ?? null,
|
|
'duration' => (int)($t['duration_minutes'] ?? 30),
|
|
], array_slice($tasks, 0, 10));
|
|
|
|
$yesterdaySummary = array_map(fn($t) => $t['title'], array_slice($unfinishedYest, 0, 5));
|
|
|
|
$systemPrompt = "Je bent de persoonlijke AI Co-Pilot van {$userName} in Fociz. "
|
|
. "Schrijf een korte, energieke en persoonlijke dagbriefing in het Nederlands voor {$dateLabel}. "
|
|
. "Wees direct, concreet en vriendelijk. Gebruik geen markdown headers, geen bullet symbolen zoals *, -, •. "
|
|
. "Geef 1-2 zinnen dagopening + 1 zin over de grootste prioriteit + 1 praktische focustip. "
|
|
. "Maximaal 60 woorden totaal. "
|
|
. "Retourneer een JSON-object met:\n"
|
|
. "- 'greeting': openingszin (max 20 woorden)\n"
|
|
. "- 'focus_task': de meest urgente taak vandaag (max 15 woorden)\n"
|
|
. "- 'tip': één concrete focustip voor vandaag (max 20 woorden)\n"
|
|
. "- 'warnings': array van strings, max 2 items (bijv. gisteren niet afgerond of overvolle dag)";
|
|
|
|
$userPrompt = json_encode([
|
|
'date' => $dateLabel,
|
|
'tasks_today' => $taskSummary,
|
|
'busy_blocks_count' => count($busyBlocks),
|
|
'unfinished_yesterday' => $yesterdaySummary,
|
|
'total_tasks' => count($tasks),
|
|
], JSON_UNESCAPED_UNICODE);
|
|
|
|
$aiRes = fociz_ai_chat([
|
|
['role' => 'system', 'content' => $systemPrompt],
|
|
['role' => 'user', 'content' => $userPrompt],
|
|
], ['json_mode' => true, 'temperature' => 0.6, 'max_tokens' => 300, 'user_id' => $userId]);
|
|
|
|
if (!$aiRes['success'] || empty($aiRes['json'])) {
|
|
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen dagbriefing genereren'];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'greeting' => $aiRes['json']['greeting'] ?? "Goedemorgen {$userName}, een nieuwe dag!",
|
|
'focus_task' => $aiRes['json']['focus_task'] ?? '',
|
|
'tip' => $aiRes['json']['tip'] ?? '',
|
|
'warnings' => $aiRes['json']['warnings'] ?? [],
|
|
'model' => $aiRes['model'] ?? 'AI',
|
|
];
|
|
}
|