From 939e9034df4aa3ad2bd4725b13c8b5c7192bb819 Mon Sep 17 00:00:00 2001 From: NDZHER <156980843+LexaFrontDev@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:32:42 +0500 Subject: [PATCH] feat: integration with ai --- .env | 30 ++++--- .gitignore | 1 + assets/app.js | 2 +- assets/react/Router.tsx | 8 +- assets/react/Services/Ctn/RequestServices.tsx | 17 +++- .../react/Services/Habits/HabitsPageLogic.tsx | 5 +- .../react/Services/Habits/HabitsService.tsx | 8 +- .../react/Services/Pomodoro/PomodoroTimer.tsx | 2 +- assets/react/Services/Tasks/TasksService.tsx | 1 - assets/react/pages/Pomodor/PomodoroPage.tsx | 12 ++- assets/react/pages/Tasks/TasksPage.tsx | 1 - .../react/pages/chunk/Habits/HabitsModal.tsx | 4 - assets/react/ui/props/Habits/PomodoroData.tsx | 1 - composer.json | 2 +- config/bundles.php | 2 +- config/services.yaml | 7 ++ docker-compose.dev.yml | 4 +- phpunit.xml.dist | 4 + public/serviceWorker/ServiceWorker.js | 2 - src/Domain/Service/AiService/AiInterface.php | 13 +++ .../Service/AiService/AiIntegration.php | 79 +++++++++++++++++++ templates/base.html.twig | 1 + .../IntegrationTest/AiIntergrationTest.php | 33 ++++++++ 23 files changed, 198 insertions(+), 41 deletions(-) create mode 100644 src/Domain/Service/AiService/AiInterface.php create mode 100644 src/Infrastructure/Service/AiService/AiIntegration.php create mode 100644 tests/Service/IntegrationTest/AiIntergrationTest.php diff --git a/.env b/.env index eefa3cf..ddaaad5 100644 --- a/.env +++ b/.env @@ -1,8 +1,10 @@ APP_ENV=dev -APP_SECRET=25aeff0645dafb0ce3dc54218fd03bed +APP_SECRET= DATABASE_URL="pgsql://TimerAppUser:user123@db:5432/TimerAppDatabase?serverVersion=13&charset=utf8" - +API_KEY= +AI_MODEL= +AI_URL= MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 @@ -19,12 +21,22 @@ MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" ###> lexik/jwt-authentication-bundle ### JWT_PRIVATE_KEY_PATH=%kernel.project_dir%/config/jwt/private.pem JWT_PUBLIC_KEY_PATH=%kernel.project_dir%/config/jwt/public.pem -JWT_PASSPHRASE=e357a5e49f1839042101695a96c07c9edfdf4939d4e91b1368d5c177153a71bc +JWT_PASSPHRASE= ###< lexik/jwt-authentication-bundle ### -VITE_CACHE_DB_NAME=HabitAiCacheDev -VITE_CACHE_STORE_NAME=cache -VITE_GOOGLE_CLIENT_ID=643971601411-8n1r7vok0pk4v4q250dfpr4ibs17jfms.apps.googleusercontent.com -VITE_GOOGLE_REDIRECT_URI=http://localhost:8000/api/auth/google/callback -VITE_PUSH_PUBLIC_KEY=BGFRrWJKC5fnlb_eLwOIqa4bWlbBqRUIFvlDYQ1GX56Hl5Kv4KNopAIqZ2Tq6ohG4hIdLJoim4313yKUyNevXgo -VITE_PUSH_PRIVATE_KEY=EE9lQvPuHsWctURErU3bC4VG9wLguK-JcLI5iTo7G94 \ No newline at end of file +VITE_CACHE_DB_NAME= +VITE_CACHE_STORE_NAME= +VITE_GOOGLE_CLIENT_ID= +VITE_GOOGLE_REDIRECT_URI= +VITE_PUSH_PUBLIC_KEY= +VITE_PUSH_PRIVATE_KEY= + +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 + +MAILER_DSN=null://null + +MERCURE_URL=https://example.com/.well-known/mercure + +MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure + +MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" diff --git a/.gitignore b/.gitignore index 0d41ca1..48d59d9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ package-lock.json /public/bundles/ /var/ /vendor/ +/ignorefiles ###< symfony/framework-bundle ### ###> phpunit/phpunit ### diff --git a/assets/app.js b/assets/app.js index 785edf2..cea4e3e 100644 --- a/assets/app.js +++ b/assets/app.js @@ -7,4 +7,4 @@ import './bootstrap.js'; */ import './styles/app.scss'; -console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉'); + diff --git a/assets/react/Router.tsx b/assets/react/Router.tsx index a8048d6..068fcf7 100644 --- a/assets/react/Router.tsx +++ b/assets/react/Router.tsx @@ -41,12 +41,10 @@ const RouterDom = () => { try { const res = await fetch('/api/auth/check'); const data = await res.json(); - console.log(data); const ok = res.ok; setIsAuthenticated(ok); if(ok){ - console.log('Зов пениса'); subscribePush(); } @@ -83,7 +81,6 @@ const RouterDom = () => { const getPlatform = () => { - console.log('Дошли') const ua = navigator.userAgent; let os = 'Unknown OS'; @@ -100,7 +97,6 @@ const RouterDom = () => { else if (/Safari/.test(ua) && !/Chrome/.test(ua)) browser = 'Safari'; else if (/Edg/.test(ua)) browser = 'Edge'; else if (/OPR/.test(ua)) browser = 'Opera'; - console.log('тоже') return `${browser}_${os}`; }; @@ -121,9 +117,7 @@ const RouterDom = () => { return; } } - console.log('Service Worker зарегистрирован:'); const registration = await navigator.serviceWorker.register('/serviceWorker/ServiceWorker.js'); - console.log('Service Worker зарегистрирован:', registration); const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, @@ -136,7 +130,7 @@ const RouterDom = () => { keys: subscription.toJSON().keys }; - console.log('Отправляем данные подписки:', pushData); + await fetch('/api/save/subscription/web', { method: 'POST', diff --git a/assets/react/Services/Ctn/RequestServices.tsx b/assets/react/Services/Ctn/RequestServices.tsx index 793034f..072da26 100644 --- a/assets/react/Services/Ctn/RequestServices.tsx +++ b/assets/react/Services/Ctn/RequestServices.tsx @@ -24,7 +24,17 @@ export class RequestServices { return response.json() as Promise; } - async get(fetchUrl: string, method: string = "GET", body?: any, accessMassiveKey?: string): Promise { + async get(fetchUrl: string, method: string = "GET", body?: any): Promise { + try { + const data = await this._request(fetchUrl, method, method !== "GET" ? body : undefined); + return data; + } catch (e) { + await this.logError("Ошибка GET-запроса", { fetchUrl, error: e }); + return false; + } + } + + async getArray(fetchUrl: string, method: string = "GET", body?: any, accessMassiveKey?: string): Promise { try { let data = await this._request(fetchUrl, method, method !== "GET" ? body : undefined); @@ -32,6 +42,11 @@ export class RequestServices { data = (data as any)[accessMassiveKey]; } + if (!Array.isArray(data)) { + if (data == null) return []; + return [data as R]; + } + return data; } catch (e) { await this.logError("Ошибка GET-запроса", { fetchUrl, error: e }); diff --git a/assets/react/Services/Habits/HabitsPageLogic.tsx b/assets/react/Services/Habits/HabitsPageLogic.tsx index 93b20a8..2355f8a 100644 --- a/assets/react/Services/Habits/HabitsPageLogic.tsx +++ b/assets/react/Services/Habits/HabitsPageLogic.tsx @@ -44,7 +44,7 @@ export const useHabitsLogic = (habitsService: HabitsService) => { return setHabits([]); } setIsLoading(false); - setHabits(result || []); + setHabits(result); }; const fetchHabitsStatistic = async () => { @@ -56,7 +56,6 @@ export const useHabitsLogic = (habitsService: HabitsService) => { } - console.log(statistic) setIsLoading(false); setHabitsStatistic(statistic || []) } @@ -71,7 +70,7 @@ export const useHabitsLogic = (habitsService: HabitsService) => { } setIsLoading(false); - setTemplates(templates || []) + setTemplates(templates) } const saveHabitProgress = async (habitId: number, countProgress: number) => { diff --git a/assets/react/Services/Habits/HabitsService.tsx b/assets/react/Services/Habits/HabitsService.tsx index 1c6c198..77ef27d 100644 --- a/assets/react/Services/Habits/HabitsService.tsx +++ b/assets/react/Services/Habits/HabitsService.tsx @@ -44,12 +44,12 @@ export class HabitsService { } - async getHabitsAll(limit: number = 50, offset: number = 0): Promise { - return await this.reqService.get(`habits`, `/api/get/Habits/all?limit=${limit}&offset=${offset}`, 'GET'); + async getHabitsAll(limit: number = 50, offset: number = 0): Promise { + return await this.reqService.getArray(`habits`, `/api/get/Habits/all?limit=${limit}&offset=${offset}`, 'GET'); } - async getHabitsTemplatesAll(): Promise{ - return await this.reqService.get(`habits_templates`, `/api/Habits/templates/all`, 'GET'); + async getHabitsTemplatesAll(): Promise{ + return await this.reqService.getArray(`habits_templates`, `/api/Habits/templates/all`, 'GET'); } async getHabitsStatisticAll(): Promise{ diff --git a/assets/react/Services/Pomodoro/PomodoroTimer.tsx b/assets/react/Services/Pomodoro/PomodoroTimer.tsx index 08eaefb..9f821ed 100644 --- a/assets/react/Services/Pomodoro/PomodoroTimer.tsx +++ b/assets/react/Services/Pomodoro/PomodoroTimer.tsx @@ -105,7 +105,7 @@ export const usePomodoroTimer = (PomodoroUseCase: PomodoroService) => { timeEnd: Math.floor(Date.now() / 1000), created_date: Math.floor(Date.now() / 1000), }; - console.log(pomodoroData); + await PomodoroUseCase.createPomodro(pomodoroData); } diff --git a/assets/react/Services/Tasks/TasksService.tsx b/assets/react/Services/Tasks/TasksService.tsx index a01b2d5..057e60f 100644 --- a/assets/react/Services/Tasks/TasksService.tsx +++ b/assets/react/Services/Tasks/TasksService.tsx @@ -27,7 +27,6 @@ export class TasksService { '/api/list/tasks/all', 'GET', undefined, - 'result' ); return response; diff --git a/assets/react/pages/Pomodor/PomodoroPage.tsx b/assets/react/pages/Pomodor/PomodoroPage.tsx index 9b664f9..1d09905 100644 --- a/assets/react/pages/Pomodor/PomodoroPage.tsx +++ b/assets/react/pages/Pomodor/PomodoroPage.tsx @@ -30,7 +30,15 @@ const langUseCase = new LangStorageUseCase(langStorage); const Pomodoro = () => { - const [dataPomodoro, setDataPomodoro] = useState(null); + const [dataPomodoro, setDataPomodoro] = useState({ + todayPomos: 0, + todayFocusTime: 0, + totalPomodorCount: 0, + habitsList: [], + tasksList: [], + pomodorHistory: [], + }); + const [activeTab, setActiveTab] = useState('Pomodoro'); const [langCode, setLangCode] = useState('en'); const { t, i18n } = useTranslation('translation'); @@ -97,7 +105,7 @@ const Pomodoro = () => { }; - if (!translationsLoaded || !dataPomodoro) return ; + if (!translationsLoaded) return ; const { todayPomos, todayFocusTime, totalPomodorCount, habitsList, tasksList, pomodorHistory } = dataPomodoro; diff --git a/assets/react/pages/Tasks/TasksPage.tsx b/assets/react/pages/Tasks/TasksPage.tsx index 0a98455..0f7bda2 100644 --- a/assets/react/pages/Tasks/TasksPage.tsx +++ b/assets/react/pages/Tasks/TasksPage.tsx @@ -142,7 +142,6 @@ const TasksPage: React.FC = () => { setLoading(true); const allTasksResult = await tasksService.getTasksAll(); setAllTasks(Array.isArray(allTasksResult) ? allTasksResult : []); - console.log(allTasks) } catch (err: any) { setError(err.message); setAllTasks([]); diff --git a/assets/react/pages/chunk/Habits/HabitsModal.tsx b/assets/react/pages/chunk/Habits/HabitsModal.tsx index 790925e..d1358dc 100644 --- a/assets/react/pages/chunk/Habits/HabitsModal.tsx +++ b/assets/react/pages/chunk/Habits/HabitsModal.tsx @@ -64,8 +64,6 @@ const HabitModal: React.FC = ({habitTemplates, onClose, onEdit, useEffect(() => { if (edit && editData) { - console.log('То что содержить edit data') - console.log(editData); const notificationRaw = editData.notification_date || ''; const timeOnly = notificationRaw.length >= 5 ? notificationRaw.substring(0, 5) : ''; @@ -87,7 +85,6 @@ const HabitModal: React.FC = ({habitTemplates, onClose, onEdit, purposeCount: editData.count, date: editData.date }); - console.log(data); setStep(2); } }, [edit, editData]); @@ -237,7 +234,6 @@ const HabitModal: React.FC = ({habitTemplates, onClose, onEdit, if (!validateStep(step) || !editData?.habit_id) return; const payload: EditDataType = { - cacheId: data.cacheId, habitId: editData.habit_id, title: data.title, quote: data.quote, diff --git a/assets/react/ui/props/Habits/PomodoroData.tsx b/assets/react/ui/props/Habits/PomodoroData.tsx index f4c45cd..effa8b4 100644 --- a/assets/react/ui/props/Habits/PomodoroData.tsx +++ b/assets/react/ui/props/Habits/PomodoroData.tsx @@ -1,5 +1,4 @@ export type PomodoroData = { - cacheId: number; todayPomos: number; todayFocusTime: number; totalPomodorCount: number; diff --git a/composer.json b/composer.json index 373b40e..acde7c4 100644 --- a/composer.json +++ b/composer.json @@ -106,7 +106,7 @@ "phpstan/phpstan": "^2.1", "phpstan/phpstan-doctrine": "^2.0", "phpstan/phpstan-symfony": "*", - "phpunit/phpunit": "^9.5", + "phpunit/phpunit": "^10", "symfony/browser-kit": "6.4.*", "symfony/css-selector": "6.4.*", "symfony/debug-bundle": "6.4.*", diff --git a/config/bundles.php b/config/bundles.php index b8ad024..d55de15 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -17,4 +17,4 @@ Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], Gesdinet\JWTRefreshTokenBundle\GesdinetJWTRefreshTokenBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], -]; \ No newline at end of file +]; diff --git a/config/services.yaml b/config/services.yaml index 20523a4..fec13ed 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -28,3 +28,10 @@ services: App\Infrastructure\Service\FilterCriteriaApply\FilterApplicator\ApplyFilter: arguments: $criterionAppliers: !tagged_iterator app.criterion_applier + + App\Infrastructure\Service\AiService\AiIntegration: + arguments: + $apiKey: '%env(API_KEY)%' + $headerName: '%env(AI_MODEL)%' + $aiUrl: '%env(AI_URL)%' + diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a0fdd9d..4831d5e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,7 +26,7 @@ services: environment: XDEBUG_MODE: "${XDEBUG_MODE:-off}" TEST_DATABASE_URL: pgsql://TimerAppUser:user123@db:5432/TimerAppDatabase?serverVersion=13&charset=utf8 - TEST_ELASTICSEARCH_INDEX_PREFIX: test_questionnaire_ + TEST_ELASTICSEARCH_INDEX_PREFIX: test_timer_app_ extra_hosts: - host.docker.internal:host-gateway db: @@ -35,7 +35,7 @@ services: db_test: image: postgres:13 ports: - - '11009:5432' + - '11010:5432' container_name: timer_app_pgsql_test restart: unless-stopped environment: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 77426b9..66958bf 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -39,5 +39,9 @@ + + + + diff --git a/public/serviceWorker/ServiceWorker.js b/public/serviceWorker/ServiceWorker.js index 046d64e..8116185 100644 --- a/public/serviceWorker/ServiceWorker.js +++ b/public/serviceWorker/ServiceWorker.js @@ -1,10 +1,8 @@ self.addEventListener('install', (event) => { - console.log('Service Worker установлен'); event.waitUntil(self.skipWaiting()); }); self.addEventListener('activate', (event) => { - console.log('Service Worker активирован'); event.waitUntil(self.clients.claim()); }); diff --git a/src/Domain/Service/AiService/AiInterface.php b/src/Domain/Service/AiService/AiInterface.php new file mode 100644 index 0000000..b422585 --- /dev/null +++ b/src/Domain/Service/AiService/AiInterface.php @@ -0,0 +1,13 @@ + Возвращает декодированный JSON как массив + */ + public function integration(string $prompt): array; + + public function generateText(string $prompt): string; +} diff --git a/src/Infrastructure/Service/AiService/AiIntegration.php b/src/Infrastructure/Service/AiService/AiIntegration.php new file mode 100644 index 0000000..002be12 --- /dev/null +++ b/src/Infrastructure/Service/AiService/AiIntegration.php @@ -0,0 +1,79 @@ +apiKey = $apiKey; + $this->headerName = $headerName; + $this->aiURL = $aiUrl; + } + + /** + * @return array Возвращает декодированный JSON как массив + * + * @throws \JsonException + */ + public function integration(string $prompt): array + { + $data = [ + 'contents' => [ + [ + 'parts' => [ + ['text' => $prompt], + ], + ], + ], + ]; + + $ch = curl_init($this->aiURL); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + "{$this->headerName}: {$this->apiKey}", + ]); + curl_setopt($ch, CURLOPT_POST, true); + $json = json_encode($data, JSON_THROW_ON_ERROR); + if (false === $json) { + throw new \RuntimeException('Failed to encode data to JSON: '.json_last_error_msg()); + } + + curl_setopt($ch, CURLOPT_POSTFIELDS, $json); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + $err = curl_error($ch); + curl_close($ch); + + if ($err) { + throw new \RuntimeException("Curl error: {$err}"); + } + + + if (false === $response) { + throw new \RuntimeException('Curl response is false'); + } + + return json_decode((string) $response, true, 512, JSON_THROW_ON_ERROR); + } + + public function generateText(string $prompt): string + { + $response = $this->integration($prompt); + + if (!isset($response['candidates'][0]['content']['parts'])) { + throw new \RuntimeException('No content in API response'); + } + + $parts = array_column($response['candidates'][0]['content']['parts'], 'text'); + + return trim(implode("\n", $parts)); + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index 2b66437..5961284 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -11,6 +11,7 @@ {% block body %}{% endblock %} {% block javascripts %} + {% block importmap %}{{ importmap('app') }}{% endblock %} diff --git a/tests/Service/IntegrationTest/AiIntergrationTest.php b/tests/Service/IntegrationTest/AiIntergrationTest.php new file mode 100644 index 0000000..28410b4 --- /dev/null +++ b/tests/Service/IntegrationTest/AiIntergrationTest.php @@ -0,0 +1,33 @@ +ai = self::getContainer()->get(AiInterface::class); + } + + public function testGenerateTextBasicPrompt(): void + { + $prompt = "у тебя спрашивает пользователь Noas о его статистике по продуктивности. Текущие привычки:\n1. Утренняя пробежка — цель 5 км в день, трекинг: 20 км за неделю, 180 км за месяц, 400 км за 5 месяцев.\n2. Чтение — цель 10 страниц в день, трекинг: 70 страниц за неделю, 300 страниц за месяц, 1500 страниц за 5 месяцев.\n3. Питье воды — цель 2 литра в день, трекинг: 14 литров за неделю, 60 литров за месяц, 300 литров за 5 месяцев.\n\nВымышленные цели:\n- Бегать 5 км каждый день\n- Читать 10 страниц ежедневно\n- Пить достаточно воды каждый день\n- Планировать день заранее\n- Делать медитацию по 10 минут\n\nИнструкция для GIMINI: анализируй статистику, предложи улучшения продуктивности и, если пользователь хочет добавить новые привычки, ответь JSON-командой в формате: {\"recommendations\":[], \"newHabits\":[], \"updateCommand\":{}}. Вопрос пользователя: 'Что ты думаешь о моей продуктивности и какие привычки мне добавить?'"; + + $response = $this->ai->generateText($prompt); + + fwrite(STDOUT, "\nPROMPT: ".$prompt); + fwrite(STDOUT, "\nRESPONSE (raw): ".print_r($response, true)); + fwrite(STDOUT, "\nRESPONSE (json): ".json_encode( + $response, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE + )); + + $this->assertNotEmpty($response, 'AI response should not be empty'); + } +}