Added database and implemented action logging

This commit is contained in:
Tim Lappe 2025-03-17 19:40:42 +01:00
parent 9590992c08
commit 0509b1be52
32 changed files with 1046 additions and 56 deletions

View File

@ -2,4 +2,6 @@ You are an expert AI programming assistant that primarily focuses on producing c
You are also an expert in Software architect and you provide very decoupled code with come abstractions.
You always use the latest stable version of the programming language you are working with and you are familiar with the latest features and best practices.
You are a full stack developer with expert knowledge Symfony and Docker.
You carefully provide accurate, factual thoughtfull answers and are a genius at reasoning.
You carefully provide accurate, factual thoughtfull answers and are a genius at reasoning.
Dont use make: to create or edit entities

4
.env
View File

@ -24,13 +24,11 @@ APP_SECRET=
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
DATABASE_URL="mysql://tars:tars@127.0.0.1:3306/tars?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> OpenAI API ###
OPENAI_MODEL=gpt-4o
OPENAI_API_URL=https://api.openai.com/v1
###< OpenAI API ###

View File

@ -4,10 +4,16 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
# Calendar configuration
app.calendars.ics:
tim_calendar: 'webcal://p101-caldav.icloud.com/published/2/MTIwODk2NzA4MjIxMjA4OX8U9-11KVNdAw-HVVfEeHJioeELY1BwErQansnsIRnd'
household_calendar: 'webcal://p101-caldav.icloud.com/published/2/MTIwODk2NzA4MjIxMjA4OX8U9-11KVNdAw-HVVfEeHJDG4lEVQV-T3I5sEk0H6vfdGGP0X9Mpef_3zp3JNiiYvbAqzkgkukXO0nsKSxY1FA'
tim_calendar:
url: 'webcal://p101-caldav.icloud.com/published/2/MTIwODk2NzA4MjIxMjA4OX8U9-11KVNdAw-HVVfEeHJioeELY1BwErQansnsIRnd'
name: 'Tims calendar'
description: 'Tims personal calendar for individual events and appointments'
household_calendar:
url: 'webcal://p101-caldav.icloud.com/published/2/MTIwODk2NzA4MjIxMjA4OX8U9-11KVNdAw-HVVfEeHJDG4lEVQV-T3I5sEk0H6vfdGGP0X9Mpef_3zp3JNiiYvbAqzkgkukXO0nsKSxY1FA'
name: 'Household calendar'
description: 'Shared household calendar for family events and coordination'
services:
# default configuration for services in *this* file

20
docker-compose.yml Normal file
View File

@ -0,0 +1,20 @@
version: '3.8'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: tars
MYSQL_USER: tars
MYSQL_PASSWORD: tars
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
command: --default-authentication-plugin=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
volumes:
mysql_data:

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250317180455 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE brain_storage_item (id INT AUTO_INCREMENT NOT NULL, description LONGTEXT NOT NULL, content LONGTEXT NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE brain_storage_item');
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250317182942 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE ai_action (id INT AUTO_INCREMENT NOT NULL, action VARCHAR(255) NOT NULL, description LONGTEXT NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', agent VARCHAR(255) DEFAULT NULL, context JSON DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE ai_action');
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250317183016 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE ai_action CHANGE description description LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE ai_action CHANGE description description LONGTEXT NOT NULL');
}
}

View File

@ -28,7 +28,7 @@ class ReadCalendarCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$days = 7;
$days = 14;
$from = new DateTime();
$to = (new DateTime())->modify("+$days days");
@ -36,7 +36,7 @@ class ReadCalendarCommand extends Command
$calendars = $this->calendarService->getCalendars();
foreach ($calendars as $calendar) {
$events = $calendar->getEvents($from, $to);
$events = $calendar->getEventsByDateRange($from, $to);
$io->section($calendar->getName());
$this->displayEvents($io, $events);
}

View File

@ -9,7 +9,9 @@ use Exception;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:run-agent',
@ -23,16 +25,43 @@ class RunAgentCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'dry-run',
'd',
InputOption::VALUE_NONE,
'Only show the prompt without sending it to the AI service'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$isDryRun = $input->getOption('dry-run');
try {
if ($isDryRun) {
$prompt = $this->agent->getPrompt();
$io->section('Prompt Preview');
$io->text($prompt);
return Command::SUCCESS;
}
$result = $this->agent->run();
$output->writeln($result['prompt']);
$output->writeln($result['response']);
$io->section('Prompt');
$io->text($result['prompt']);
$io->section('Response');
if (is_array($result['response'])) {
$io->writeln(json_encode($result['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
} else {
$io->writeln($result['response']);
}
return Command::SUCCESS;
} catch (Exception $e) {
$output->writeln('<error>'.$e->getMessage().'</error>');
$io->error($e->getMessage());
return Command::FAILURE;
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Service\AiActionLogger;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/ai-actions')]
class AiActionController extends AbstractController
{
public function __construct(
private readonly AiActionLogger $aiActionLogger
) {
}
#[Route('/', name: 'app_ai_actions_index')]
public function index(): Response
{
$actions = $this->aiActionLogger->getRecentActions();
return $this->render('ai_action/index.html.twig', [
'actions' => $actions,
]);
}
#[Route('/agent/{agent}', name: 'app_ai_actions_agent')]
public function agentActions(string $agent): Response
{
$since = new \DateTimeImmutable('-7 weeks');
$actions = $this->aiActionLogger->getAgentActions($agent, $since);
return $this->render('ai_action/agent.html.twig', [
'actions' => $actions,
'agent' => $agent,
]);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Core\Actions;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
#[Autoconfigure(tags: ['app.action'])]
interface ActionInterface
{
public function getName(): string;
public function getDescription(): string;
/**
* @return array<string, array{type: string, description: string}>
*/
public function getParameters(): array;
public function execute(array $parameters): void;
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Core\Actions;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final readonly class ActionsProvider
{
public function __construct(
#[TaggedIterator('app.action')]
private iterable $actions,
) {
}
/**
* @return array<ActionInterface>
*/
public function getActions(): array
{
return iterator_to_array($this->actions);
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Core\Actions;
use App\Entity\BrainStorageItem;
use Doctrine\ORM\EntityManagerInterface;
final readonly class BrainStorageAction implements ActionInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {
}
public function getName(): string
{
return 'brain_storage';
}
public function getDescription(): string
{
return 'Stores information for yourself for future use. You can store any information you want to remember.';
}
public function getParameters(): array
{
return [
'description' => [
'type' => 'string',
'description' => 'The description of the information to be stored',
],
'content' => [
'type' => 'string',
'description' => 'The content of the information to be stored',
],
];
}
public function execute(array $parameters): void
{
$item = new BrainStorageItem();
$item->setDescription($parameters['description']);
$item->setContent($parameters['content']);
$this->entityManager->persist($item);
$this->entityManager->flush();
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Core\Actions;
final readonly class ImAliveAction implements ActionInterface
{
public function getName(): string
{
return 'im_alive';
}
public function getDescription(): string
{
return 'Return this action in your response to indicate that you are alive and well.';
}
public function getParameters(): array
{
return [
];
}
public function execute(array $parameters): void
{
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Core\Actions;
final readonly class PlanAutomationAction implements ActionInterface
{
public function getName(): string
{
return 'plan_automation';
}
public function getDescription(): string
{
return 'Plans an automation based on provided parameters';
}
public function getParameters(): array
{
return [
'description' => [
'type' => 'string',
'description' => 'The description of the automation',
],
'datetime' => [
'type' => 'YYYY-MM-DD HH:MM:SS',
'description' => 'The datetime when the automation should be executed',
],
'actions' => [
'type' => 'array',
'description' => 'The actions to be performed in the automation. Each action is an array with the following keys: "action" ("turn_on_light", "turn_off_light"), "parameters" (array of parameters for the action).',
],
];
}
public function execute(array $parameters): void
{
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Core\Actions;
final class SendNotificationAction implements ActionInterface
{
public function __construct(
) {
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return 'Send a notification to the user';
}
/**
* @inheritDoc
*/
public function getName(): string
{
return 'send_notification';
}
/**
* @inheritDoc
*/
public function getParameters(): array
{
return [
'recipient' => [
'type' => 'string',
'description' => 'The recipient of the notification (e.g. "tim", "cara", "household")',
],
'title' => [
'type' => 'string',
'description' => 'The title of the notification',
],
'message' => [
'type' => 'string',
'description' => 'The message to send to the user',
],
];
}
/**
* @inheritDoc
*/
public function execute(array $parameters): void
{
}
}

View File

@ -4,8 +4,13 @@ declare(strict_types=1);
namespace App\Core\Agent;
use App\Core\Actions\ActionsProvider;
use App\Core\Services\AI\ChatServiceInterface;
use App\Core\Services\Calendar\CalendarService;
use App\Core\Services\Home\HomeServiceInterface;
use App\Repository\BrainStorageItemRepository;
use App\Repository\AiActionRepository;
use App\Service\AiActionLogger;
use DateTimeImmutable;
use function sprintf;
@ -16,7 +21,12 @@ class Agent
public function __construct(
private readonly PromptProvider $promptProvider,
private readonly CalendarService $calendarService,
private readonly HomeServiceInterface $homeService,
private readonly ChatServiceInterface $chatGPTService,
private readonly ActionsProvider $actionsProvider,
private readonly BrainStorageItemRepository $brainStorageItemRepository,
private readonly AiActionLogger $aiActionLogger,
private readonly AiActionRepository $aiActionRepository,
) {
}
@ -24,6 +34,26 @@ class Agent
{
$prompt = $this->getPrompt();
$response = $this->chatGPTService->sendMessage($prompt);
$response = trim($response);
$response = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response: ' . json_last_error_msg());
}
if (!is_array($response)) {
throw new \RuntimeException('Invalid JSON response: ' . json_last_error_msg());
}
foreach ($response as $action) {
$this->aiActionLogger->logAction(
$action['action'],
$action['description'] ?? null,
'agent',
$action['parameters'] ?? [],
);
}
return [
'prompt' => $prompt,
@ -40,17 +70,95 @@ class Agent
$calendars = $this->calendarService->getCalendars();
$calendarEventsText = '';
$smartHomeDevices = $this->homeService->findAllEntities();
$smartHomeDevicesText = '';
foreach ($smartHomeDevices as $smartHomeDevice) {
$smartHomeDevicesText .= sprintf(
"- %s: %s (%s)\n",
$smartHomeDevice->getId(),
$smartHomeDevice->getName(),
$smartHomeDevice->getState(),
);
}
foreach ($calendars as $calendar) {
$calendarEventsText .= sprintf(
"- %s: %s\n",
$calendar->getName(),
$calendar->getDescription(),
);
$events = $calendar->getEventsByDateRange($from, $to);
foreach ($events as $event) {
$calendarEventsText .= sprintf(
" * %s - %s: %s%s\n",
$event->getStart()->format('Y-m-d H:i'),
$event->getEnd()->format('H:i'),
$event->getTitle(),
$event->getLocation() ? ' @ ' . $event->getLocation() : ''
);
}
}
return strtr($this->promptProvider->getPromptTemplate(), [
$brainStorageItems = $this->brainStorageItemRepository->findAll();
$brainStorageText = '';
foreach ($brainStorageItems as $brainStorageItem) {
$brainStorageText .= sprintf("- %s: %s\n", $brainStorageItem->getDescription(), $brainStorageItem->getContent());
}
if (empty($brainStorageText)) {
$brainStorageText = 'No information stored in your brain.';
}
$availableActions = '';
foreach ($this->actionsProvider->getActions() as $action) {
$availableActions .= sprintf("- %s: %s\n", $action->getName(), $action->getDescription());
$parameters = $action->getParameters();
if (!empty($parameters)) {
$availableActions .= " Parameters:\n";
foreach ($parameters as $paramName => $paramDetails) {
$availableActions .= sprintf(
" * %s: %s (%s)\n",
$paramName,
$paramDetails['type'],
$paramDetails['description']
);
}
}
}
$since = new DateTimeImmutable('-7 days');
$performedActions = $this->aiActionRepository->findRecentActions(100);
$performedActionsText = '';
if (empty($performedActions)) {
$performedActionsText = 'No actions performed in the last 7 days.';
} else {
foreach ($performedActions as $performedAction) {
if ($performedAction->getAction() === 'im_alive') {
continue;
}
$performedActionsText .= sprintf(
"- %s (%s): %s \n%s\n",
$performedAction->getAction(),
$performedAction->getCreatedAt()->format('Y-m-d H:i'),
$performedAction->getDescription(),
json_encode($performedAction->getContext(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
}
}
$response = trim(strtr($this->promptProvider->getPromptTemplate(), [
'{calendar_events}' => $calendarEventsText,
'{smart_home_devices}' => $smartHomeDevicesText,
'{current_time}' => $now->format('Y-m-d H:i:s'),
]);
'{available_actions}' => $availableActions,
'{brain_storage}' => $brainStorageText,
'{performed_actions}' => $performedActionsText,
]));
return $response;
}
}

View File

@ -9,23 +9,30 @@ class PromptProvider
public function getPromptTemplate(): string
{
return <<<'EOT'
You are a high level smart home assistant.
You can answer questions and help with tasks.
You can also control the smart home devices.
You can also control the calendar.
Tim and Cara are both members of the household.
You have access to the following calendars:
- Tim's calendar
- Cara's calendar
- Household calendar
These are the events from the calendars:
{calendar_events}
Its currently {current_time}
I will ask you every 5 minutes to perform actions in the smart home.
Sometimes you have to do something, but sometimes you dont.
So its your turn. What actions to you want to perform?
Answer with a JSON array of actions.
You are a high level smart home assistant.
You can answer questions and help with tasks.
You can also control the smart home devices.
You can also control the calendar.
Tim and Cara are both members of the household.
You have access to the following calendars with the events from the next 14 days:
{calendar_events}
You have also access to the states of the following smart home devices:
{smart_home_devices}
The following information is stored yourself in the past:
{brain_storage}
Its currently {current_time}
I will ask you every 60 minutes to perform actions.
Sometimes you have to do something, but sometimes you dont.
So its your turn. What actions to you want to perform?
Answer with a JSON array of actions.
You can use the following actions:
{available_actions}
If you dont want to perform any actions, just answer with an empty array.
These are the actions you have already performed in the past:
{performed_actions}
EOT;
}
}

View File

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Core\Services\Calendar;
use DateTimeInterface;
class Calendar
{
/** @var CalendarEvent[] */
@ -51,6 +53,20 @@ class Calendar
return $this->events;
}
public function getEventsByDate(DateTimeInterface $date): array
{
return array_filter($this->events, function (CalendarEvent $event) use ($date) {
return $event->getStart()->format('Y-m-d') === $date->format('Y-m-d');
});
}
public function getEventsByDateRange(DateTimeInterface $start, DateTimeInterface $end): array
{
return array_filter($this->events, function (CalendarEvent $event) use ($start, $end) {
return $event->getStart() >= $start && $event->getStart() <= $end;
});
}
public function addEvent(CalendarEvent $event): void
{
$this->events[] = $event;

View File

@ -17,6 +17,9 @@ class CalendarService
) {
}
/**
* @return Calendar[]
*/
public function getCalendars(): array
{
$allCalendars = [];

98
src/Entity/AiAction.php Normal file
View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AiActionRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AiActionRepository::class)]
class AiAction
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $action = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $agent = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $context = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getAction(): ?string
{
return $this->action;
}
public function setAction(string $action): static
{
$this->action = $action;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getAgent(): ?string
{
return $this->agent;
}
public function setAgent(?string $agent): static
{
$this->agent = $agent;
return $this;
}
public function getContext(): ?array
{
return $this->context;
}
public function setContext(?array $context): static
{
$this->context = $context;
return $this;
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'brain_storage_item')]
class BrainStorageItem
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: Types::TEXT)]
private string $description;
#[ORM\Column(type: Types::TEXT)]
private string $content;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column]
private \DateTimeImmutable $updatedAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getDescription(): string
{
return $this->description;
}
public function setDescription(string $description): self
{
$this->description = $description;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(\DateTimeImmutable $updatedAt): self
{
$this->updatedAt = $updatedAt;
return $this;
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AiAction;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class AiActionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AiAction::class);
}
/**
* @return AiAction[]
*/
public function findRecentActions(int $limit = 100): array
{
return $this->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* @return AiAction[]
*/
public function findActionsByAgent(string $agent, \DateTimeImmutable $since): array
{
return $this->createQueryBuilder('a')
->andWhere('a.agent = :agent')
->andWhere('a.createdAt >= :since')
->setParameter('agent', $agent)
->setParameter('since', $since)
->orderBy('a.createdAt', 'DESC')
->getQuery()
->getResult();
}
public function save(AiAction $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\BrainStorageItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BrainStorageItem>
*/
class BrainStorageItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BrainStorageItem::class);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\AiAction;
use App\Repository\AiActionRepository;
use Doctrine\ORM\EntityManagerInterface;
class AiActionLogger
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AiActionRepository $aiActionRepository
) {
}
public function logAction(string $action, ?string $description = null, ?string $agent = null, ?array $context = null): void
{
$aiAction = new AiAction();
$aiAction->setAction($action);
$aiAction->setDescription($description);
$aiAction->setAgent($agent);
$aiAction->setContext($context);
$this->aiActionRepository->save($aiAction, true);
}
public function getRecentActions(int $limit = 100): array
{
return $this->aiActionRepository->findRecentActions($limit);
}
public function getAgentActions(string $agent, \DateTimeImmutable $since): array
{
return $this->aiActionRepository->findActionsByAgent($agent, $since);
}
}

View File

@ -28,16 +28,16 @@ class IcsCalendarProvider implements CalendarProviderInterface
{
$calendars = [];
foreach ($this->icsCalendars as $name => $url) {
foreach ($this->icsCalendars as $calendarConfig) {
$calendar = new Calendar(
name: $name,
description: sprintf('ICS Calendar: %s', $name),
url: $url,
name: $calendarConfig['name'],
description: $calendarConfig['description'],
url: $calendarConfig['url'],
enabled: true,
isDefault: false,
);
$content = $this->icsClient->fetchCalendarContent($url);
$content = $this->icsClient->fetchCalendarContent($calendarConfig['url']);
$this->icsParser->parseEvents($calendar, $content);
$calendars[] = $calendar;

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Services\HomeAssistant;
use App\Core\Services\Home\HomeEntityInterface;
use App\Core\Services\Home\HomeEntityType;
use App\Core\Services\Home\HomeServiceInterface;
use function array_filter;
@ -35,10 +36,18 @@ final readonly class HomeAssistantHomeService implements HomeServiceInterface
throw new HomeAssistantException('No entities found');
}
return array_map(
fn (EntityState $state) => new HomeAssistantEntity($state),
$states,
);
$entities = [];
foreach ($states as $state) {
$type = HomeEntityType::tryFrom($state->getDomain());
if ($type === null) {
continue;
}
$entities[] = new HomeAssistantEntity($state);
}
return array_filter($entities, fn (HomeEntityInterface $entity) => $entity->getType() === HomeEntityType::LIGHT);
}
public function callService(string $service, array $data = []): array

View File

@ -15,19 +15,16 @@ class OpenAIClient
#[Autowire('%env(OPENAI_API_KEY)%')]
private readonly string $apiKey,
#[Autowire('%env(OPENAI_API_URL)%')]
private readonly string $apiUrl,
#[Autowire('%env(OPENAI_MODEL)%')]
private readonly string $model,
private readonly string $apiUrl
) {
}
public function chat(array $messages, float $temperature = 0.7, int $maxTokens = 2048): array
{
$response = $this->sendRequest('/chat/completions', [
'model' => $this->model,
'model' => 'o3-mini',
'messages' => $messages,
'temperature' => $temperature,
'max_tokens' => $maxTokens,
'reasoning_effort' => 'low',
]);
return $response->toArray();

68
templates/_menu.html.twig Normal file
View File

@ -0,0 +1,68 @@
{% set current_route = app.request.get('_route') %}
<nav class="bg-white shadow-sm rounded-lg mb-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<a href="{{ path('app_home') }}" class="text-xl font-bold text-gray-800">
Tars AI
</a>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="{{ path('app_home') }}"
class="{% if current_route == 'app_home' %}border-indigo-500 text-gray-900{% else %}border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Chat
</a>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="{{ path('app_ai_actions_index') }}"
class="{% if current_route == 'app_ai_actions_index' %}border-indigo-500 text-gray-900{% else %}border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Actions
</a>
</div>
</div>
<div class="hidden sm:ml-6 sm:flex sm:items-center">
<div class="ml-3 relative">
<div class="flex items-center space-x-4">
<a href="{{ path('app_home') }}" class="text-gray-500 hover:text-gray-700">
<span class="sr-only">Settings</span>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</a>
</div>
</div>
</div>
<div class="-mr-2 flex items-center sm:hidden">
<button type="button" class="mobile-menu-button inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500">
<span class="sr-only">Open main menu</span>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
</div>
<div class="sm:hidden mobile-menu hidden">
<div class="pt-2 pb-3 space-y-1">
<a href="{{ path('app_home') }}"
class="{% if current_route == 'app_home' %}bg-indigo-50 border-indigo-500 text-indigo-700{% else %}border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700{% endif %} block pl-3 pr-4 py-2 border-l-4 text-base font-medium">
Chat
</a>
</div>
</div>
</nav>
<script>
document.addEventListener('DOMContentLoaded', function() {
const mobileMenuButton = document.querySelector('.mobile-menu-button');
const mobileMenu = document.querySelector('.mobile-menu');
mobileMenuButton.addEventListener('click', function() {
mobileMenu.classList.toggle('hidden');
});
});
</script>

View File

@ -0,0 +1,39 @@
{% extends 'base.html.twig' %}
{% block title %}AI Actions by {{ agent }}{% endblock %}
{% block body %}
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Actions by {{ agent }}</h1>
<a href="{{ path('app_ai_actions_index') }}" class="text-blue-600 hover:text-blue-800">
← Back to all actions
</a>
</div>
<div class="grid gap-4">
{% for action in actions %}
<div class="bg-white rounded-lg shadow p-6">
<div class="flex justify-between items-start">
<div>
<h2 class="text-xl font-semibold text-gray-800">{{ action.action }}</h2>
<p class="text-gray-600 mt-2">{{ action.description }}</p>
</div>
<div class="text-sm text-gray-500">
{{ action.createdAt|date('Y-m-d H:i:s') }}
</div>
</div>
{% if action.context %}
<div class="mt-4">
<pre class="bg-gray-100 p-4 rounded text-sm overflow-x-auto">{{ action.context|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
</div>
{% endif %}
</div>
{% else %}
<div class="text-center text-gray-500 py-8">
No actions found for this agent
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends 'base.html.twig' %}
{% block title %}AI Actions{% endblock %}
{% block body %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">Recent AI Actions</h1>
<div class="grid gap-4">
{% for action in actions %}
<div class="bg-white rounded-lg shadow p-6">
<div class="flex justify-between items-start">
<div>
<h2 class="text-xl font-semibold text-gray-800">{{ action.action }}</h2>
<p class="text-gray-600 mt-2">{{ action.description }}</p>
{% if action.agent %}
<a href="{{ path('app_ai_actions_agent', {'agent': action.agent}) }}"
class="text-blue-600 hover:text-blue-800 mt-2 inline-block">
View all actions by {{ action.agent }}
</a>
{% endif %}
</div>
<div class="text-sm text-gray-500">
{{ action.createdAt|date('Y-m-d H:i:s') }}
</div>
</div>
{% if action.context %}
<div class="mt-4">
<pre class="bg-gray-100 p-4 rounded text-sm overflow-x-auto">{{ action.context|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
</div>
{% endif %}
</div>
{% else %}
<div class="text-center text-gray-500 py-8">
No actions found
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -10,17 +10,17 @@
{% block stylesheets %}{% endblock %}
</head>
<body class="bg-gray-50">
<div class="container py-4">
<header class="pb-3 mb-4 border-bottom">
<a href="/" class="d-flex align-items-center text-dark text-decoration-none">
<span class="fs-4">Tars AI</span>
</a>
</header>
<div class="min-h-screen flex flex-col">
{% include '_menu.html.twig' %}
<main class="flex-grow container mx-auto px-4 py-8">
{% block body %}{% endblock %}
</main>
{% block body %}{% endblock %}
<footer class="pt-3 mt-4 text-muted border-top">
&copy; {{ 'now'|date('Y') }} Tars AI
<footer class="bg-white border-t mt-auto">
<div class="container mx-auto px-4 py-6">
<p class="text-center text-gray-500">&copy; {{ 'now'|date('Y') }} Tars AI</p>
</div>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>