Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5123d62
feat: salvataggio bozze nello storico e configurazioni di prompt riut…
angelamaule Aug 2, 2026
c22d6a6
implementazione caricamento con metadati
angelica-gastal Aug 3, 2026
5a390bd
aggiunta e rimozione generazione dai preferiti
eleonorabellet Aug 3, 2026
c8f22b2
fix tipo isFavorite
eleonorabellet Aug 4, 2026
f1a59ac
fix code coverage
angelica-gastal Aug 4, 2026
0ea35c5
aggiunto email e data ora
eleonorabellet Aug 3, 2026
a4bd337
fix
eleonorabellet Aug 5, 2026
858987e
fix
eleonorabellet Aug 5, 2026
429b93b
fix coverage
eleonorabellet Aug 5, 2026
f1f8c84
fix: ripara UC-39.12/UC-39.15, rimuove migrazione e schema OpenAPI du…
angelamaule Aug 5, 2026
ddf4d98
fix(security): aggiorna guzzle e Angular per chiudere le vulnerabilit…
angelamaule Aug 5, 2026
6ddd476
test: copre copyRecipientEmail per far passare il gate di coverage su…
angelamaule Aug 5, 2026
ff39ac6
fix(security): aggiorna guzzle e Angular per chiudere le vulnerabilit…
angelamaule Aug 5, 2026
5b2ff88
feat: integra il salvataggio delle bozze nello storico e le configura…
subnetMusk Aug 5, 2026
2bfc109
docs: allinea i commenti del servizio assistente al comportamento rea…
subnetMusk Aug 5, 2026
3b50ff1
feat: integra i preferiti sulle generazioni
subnetMusk Aug 5, 2026
9175b1d
docs: allinea allo stato reale il perimetro dell'approvazione e il co…
subnetMusk Aug 5, 2026
7bb86db
chore: rigenera il client OpenAPI con orval 8.23.0
angelamaule Aug 5, 2026
cc53afb
feat: integra email destinatario e data di caricamento nel dettaglio …
subnetMusk Aug 5, 2026
39bc2a0
feat: integra la classificazione e i metadati manuali in fase di cari…
subnetMusk Aug 6, 2026
e5db06c
fix: riallinea la confidenza a UC-39.10, misurandola sulla sola estra…
subnetMusk Aug 6, 2026
6d68244
fix: esclude dal contesto di build i node_modules dei workspace
subnetMusk Aug 6, 2026
fe1cf42
docs: allinea il perimetro del Co-Pilot allo stato reale
subnetMusk Aug 6, 2026
359b4af
chore: porta orval alla 8.23.0 e rigenera il client
subnetMusk Aug 6, 2026
b795dce
fix(security): aggiorna jmespath alla 2.9.2
subnetMusk Aug 6, 2026
19f8ec7
ci: aggiunge il gate sugli advisory delle dipendenze PHP di produzione
subnetMusk Aug 6, 2026
a1ed092
fix: ripristina gli accenti e corregge un refuso nei testi dell'inter…
subnetMusk Aug 6, 2026
6c3b1f9
fix: aggiunge la colonna della data di caricamento al calcolo delle l…
subnetMusk Aug 6, 2026
3ff58ce
fix: corregge allineamento e messaggi di errore nel dettaglio del sot…
subnetMusk Aug 6, 2026
77b778f
docs: allinea la panoramica implementativa e il README alle funzional…
subnetMusk Aug 6, 2026
58bf364
docs: documenta il gate sugli advisory delle dipendenze PHP
subnetMusk Aug 6, 2026
e069478
fix(security): aggiorna commonmark alla 2.9.0, che chiude quattro den…
subnetMusk Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ infra

vendor
node_modules
**/node_modules
apps/frontend/dist

.env
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,20 @@ jobs:
if-no-files-found: warn
retention-days: 7

# Controparte PHP dell'audit npm del job frontend: come quello, guarda le
# sole dipendenze di produzione ed e' in coda, cosi' un advisory non
# nasconde l'esito dei test. I pacchetti abbandonati vengono riportati ma
# non fanno fallire il job: non sono vulnerabilita'.
# --locked audita quanto dichiarato dal lock, non il vendor cotto
# nell'immagine, cosi' il gate resta indipendente dal momento del build.
# La soglia e' applicata dallo script: composer da solo fallisce su
# qualsiasi severita' e non sa filtrarla.
- name: Audit production PHP dependencies
run: >
docker compose run --rm --no-deps app
composer audit --locked --no-dev --abandoned=report --format=json
| node scripts/ci/check-composer-advisories.mjs

frontend:
name: Frontend checks
runs-on: ubuntu-latest
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,25 @@ I due contenuti hanno criticità diverse e sono trattati di conseguenza: senza t

A generazione conclusa l’operatore può aprire l’anteprima del documento finale impaginato ed esportarlo in PDF. Ogni pagina riporta il marcatore «Creato da AI Assistant», così la provenienza del contenuto resta leggibile anche fuori dall’applicativo. Il PDF viene materializzato sullo storage a oggetti alla prima richiesta e riusato finché il contenuto non cambia.

Sulla bozza l’operatore mantiene il controllo: può correggere a mano titolo e testo finché è in lavorazione, chiedere una nuova variante che rigenera testo e copertina conservando prompt, tono e stile, scartarla mantenendola tracciata, oppure eliminarla definitivamente dallo storico. Può inoltre assegnare una valutazione da 1 a 5 stelle con un commento facoltativo, una sola volta per generazione: è il segnale di qualità percepita, esposto anche come metrica. Lo storico è filtrabile per parola chiave, tono, stile e giorno di creazione, con i criteri applicati lato API.
Sulla bozza l’operatore mantiene il controllo: può correggere a mano titolo e testo, chiedere una nuova variante che rigenera testo e copertina conservando prompt, tono e stile, scartarla mantenendola tracciata, oppure eliminarla definitivamente dallo storico. Può inoltre assegnare una valutazione da 1 a 5 stelle con un commento facoltativo, una sola volta per generazione: è il segnale di qualità percepita, esposto anche come metrica.

Nello storico entra solo ciò che l’operatore decide di conservare: una bozza vi compare dopo un salvataggio esplicito, e finché non lo riceve resta nell’area di lavoro corrente. Il salvataggio decide cosa viene archiviato, non congela il contenuto, che resta modificabile e rigenerabile come prima. Le generazioni archiviate si possono contrassegnare come preferite e lo storico è filtrabile per parola chiave, tono, stile e giorno di creazione, con i criteri applicati lato API.

I parametri di partenza si possono riusare senza ripartire da zero: testo, tono e stile del form si salvano come preset con un nome a scelta, e se il nome manca o è già in uso il sistema ne assegna uno progressivo. Riaprire un preset ricompila il form senza avviare alcuna generazione.

Il flusso evidenzia il ruolo del backend come livello di controllo tra interfaccia e modello AI: il provider genera il contenuto, mentre l’applicazione mantiene responsabilità su validazione, persistenza, stato e tracciabilità.

## Flusso documentale: Co-Pilot CdL

Il flusso Co-Pilot documentale gestisce PDF multi-destinatario caricati dall’operatore. Dopo l’upload, il backend valida il file, registra il documento originale, salva il contenuto nello storage S3-compatible e avvia una state machine in LocalStack Step Functions.

Chi carica il documento può dichiararne fin da subito tipologia, azienda, mese e anno. Sono tutti facoltativi, ma quello che dichiara fa fede: l’AI continua a produrre la propria estrazione, che resta consultabile, e i campi indicati a mano non vengono sovrascritti. Dichiararli non gonfia però la confidenza, che misura quanto ha riconosciuto il modello e quindi valuta soltanto i campi rimasti a suo carico.

La state machine pubblica task su SQS usando il callback pattern con task token. I worker Laravel consumano i messaggi, eseguono le fasi previste e notificano a Step Functions il completamento o il fallimento del task. Le fasi principali comprendono OCR, split logico del documento, estrazione dei dati, generazione dei sotto-documenti, aggiornamento dello stato e registrazione degli eventi applicativi.

Il risultato è una pipeline documentale composta da passaggi isolati, monitorabili e riavviabili, con persistenza dello stato e visibilità sui risultati prodotti.

Sui sotto-documenti prodotti l’operatore lavora in revisione human-in-the-loop: corregge i campi estratti (inclusi email destinatario, codice fiscale e matricola, con validazione dedicata) e li marca come validati. Da ogni sotto-documento il sistema compone un messaggio di invio precompilato con destinatario, oggetto e testo, che si può correggere, visualizzare in anteprima ed esportare in PDF. Il recapito avviene fuori dalla piattaforma tramite canali terzi: per questo lo stato di invio coincide con l’avvenuto **scaricamento** del PDF, e non con un invio effettuato dal sistema. Lo storico dei documenti è filtrabile per nome, cognome o azienda, stato di invio, soglia di confidenza e periodo, sempre con i criteri applicati lato API e limitati al tenant chiamante.
Sui sotto-documenti prodotti l’operatore lavora in revisione human-in-the-loop: corregge i campi estratti (inclusi email destinatario, codice fiscale e matricola, con validazione dedicata) e li marca come validati. Il dettaglio riporta anche l’email del destinatario, copiabile negli appunti con un comando, e la data e ora di caricamento del documento di origine. Da ogni sotto-documento il sistema compone un messaggio di invio precompilato con destinatario, oggetto e testo, che si può correggere, visualizzare in anteprima ed esportare in PDF. Il recapito avviene fuori dalla piattaforma tramite canali terzi: per questo lo stato di invio coincide con l’avvenuto **scaricamento** del PDF, e non con un invio effettuato dal sistema. Lo storico dei documenti è filtrabile per nome, cognome o azienda, stato di invio, soglia di confidenza e periodo, sempre con i criteri applicati lato API e limitati al tenant chiamante.

## Monitoring e osservabilità

Expand Down
112 changes: 103 additions & 9 deletions app/Http/Controllers/Api/V1/CommunicationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ class CommunicationController
use AuthorizesCommunications, ResolvesActor;

/**
* Storico delle bozze del tenant, filtrabile (UC-15..UC-18). Come lo
* storico esposto in `state.assistant.history`, esclude le bozze scartate:
* restano tracciate ma fuori dall'area di lavoro dell'operatore.
* Storico del tenant, filtrabile (UC-15..UC-18). Una bozza vi entra solo
* dopo un salvataggio esplicito (UC-9): finche' resta in stato draft (o
* dopo uno scarto) non compare qui, e' visibile solo nell'area di lavoro
* corrente dell'operatore.
*/
public function index(ListCommunicationsRequest $request, MvpStateService $state): JsonResponse
{
Expand All @@ -39,7 +40,7 @@ public function index(ListCommunicationsRequest $request, MvpStateService $state

$query = Communication::query()
->where('tenant_id', $actor->tenantId)
->where('status', '!=', CommunicationStatus::Discarded);
->where('status', CommunicationStatus::Approved);

if ($keyword = trim((string) ($filters['keyword'] ?? ''))) {
$query->where('prompt', 'like', '%'.$keyword.'%');
Expand Down Expand Up @@ -85,6 +86,7 @@ public function store(
'generation_status' => CommunicationGenerationStatus::Pending,
'cover_status' => CoverImageStatus::Pending,
'status' => CommunicationStatus::Draft,
'is_favorite' => false,
]);

$audit->record(
Expand All @@ -108,6 +110,62 @@ public function store(
], 202);
}

public function favorite(Request $request, Communication $communication, AuditLogger $audit, MvpStateService $state): JsonResponse
{
$actor = $this->actor($request);
$this->assertCommunicationOwnership($communication, $actor);

if ($communication->is_favorite) {
throw ValidationException::withMessages([
'communication' => ['La generazione è già contrassegnata come preferita.'],
]);
}

$communication->update(['is_favorite' => true]);
$audit->record(
'mvp-communication-favorited',
$actor,
'communication',
(string) $communication->id,
[],
$request,
);

return response()->json([
'message' => 'Generazione aggiunta ai preferiti.',
'communication' => $state->communication($communication->fresh()),
'state' => $state->forActor($actor),
]);
}

public function unfavorite(Request $request, Communication $communication, AuditLogger $audit, MvpStateService $state): JsonResponse
{
$actor = $this->actor($request);
$this->assertCommunicationOwnership($communication, $actor);

if (! $communication->is_favorite) {
throw ValidationException::withMessages([
'communication' => ['La generazione non è contrassegnata come preferita.'],
]);
}

$communication->update(['is_favorite' => false]);
$audit->record(
'mvp-communication-unfavorited',
$actor,
'communication',
(string) $communication->id,
[],
$request,
);

return response()->json([
'message' => 'Generazione rimossa dai preferiti.',
'communication' => $state->communication($communication->fresh()),
'state' => $state->forActor($actor),
]);
}

public function update(
UpdateCommunicationRequest $request,
Communication $communication,
Expand All @@ -117,11 +175,7 @@ public function update(
$actor = $this->actor($request);
$this->assertCommunicationOwnership($communication, $actor);

if ($communication->status !== CommunicationStatus::Draft) {
throw ValidationException::withMessages([
'communication' => ['Solo le bozze in stato draft sono modificabili.'],
]);
}
$this->assertCommunicationIsEditable($communication);

$validated = $request->validated();

Expand Down Expand Up @@ -166,6 +220,46 @@ public function regenerate(
], 202);
}

/**
* Rende la bozza visibile nello storico (UC-9): resta comunque
* modificabile e rigenerabile come prima, il salvataggio decide solo
* cosa compare nell'elenco, non blocca il contenuto.
*
* @throws AuthorizationException
*/
public function save(
Request $request,
Communication $communication,
AuditLogger $audit,
MvpStateService $state,
): JsonResponse {
$actor = $this->actor($request);
$this->assertCommunicationOwnership($communication, $actor);

abort_if(
$communication->status !== CommunicationStatus::Draft,
422,
'Solo le bozze in stato draft possono essere salvate nello storico.',
);

$communication->update(['status' => CommunicationStatus::Approved]);

$audit->record(
'mvp-communication-saved',
$actor,
'communication',
(string) $communication->id,
[],
$request,
);

return response()->json([
'message' => 'Bozza salvata nello storico.',
'communication' => $state->communication($communication->refresh()),
'state' => $state->forActor($actor),
]);
}

/**
* @throws AuthorizationException
*/
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/Api/V1/CommunicationCoverController.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public function updateCoverImage(
): JsonResponse {
$actor = $this->actor($request);
$this->assertCommunicationOwnership($communication, $actor);
$this->assertCommunicationIsEditable($communication);

/** @var UploadedFile $file */
$file = $request->file('image');
Expand Down Expand Up @@ -72,6 +73,7 @@ public function removeCoverImage(
): JsonResponse {
$actor = $this->actor($request);
$this->assertCommunicationOwnership($communication, $actor);
$this->assertCommunicationIsEditable($communication);

$covers->remove($communication);

Expand Down
12 changes: 12 additions & 0 deletions app/Http/Controllers/Api/V1/Concerns/AuthorizesCommunications.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ private function assertCommunicationReadyForExport(Communication $communication)
}
}

/**
* @throws ValidationException
*/
private function assertCommunicationIsEditable(Communication $communication): void
{
if ($communication->status === CommunicationStatus::Discarded) {
throw ValidationException::withMessages([
'communication' => ['Una bozza scartata non e\' modificabile.'],
]);
}
}

private function assertCommunicationCanRegenerate(Communication $communication): void
{
abort_if(
Expand Down
8 changes: 6 additions & 2 deletions app/Http/Controllers/Api/V1/DocumentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,18 @@ public function store(UploadDocumentRequest $request, DocumentProcessingService
{
$validated = $request->validated();
$actor = $this->actor($request);
$manualMetadata = $request->manualMetadata();

$original = $documents->storeUpload($validated['document'], $actor);
$original = $documents->storeUpload($validated['document'], $actor, $manualMetadata);
$audit->record(
'mvp-document-upload-accepted',
$actor,
'original_document',
(string) $original->id,
['filename' => $original->original_filename],
[
'filename' => $original->original_filename,
'manual_metadata' => array_filter($manualMetadata, static fn ($value) => $value !== null),
],
$request,
);

Expand Down
97 changes: 97 additions & 0 deletions app/Http/Controllers/Api/V1/PromptConfigurationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Api\V1\Concerns\ResolvesActor;
use App\Http\Requests\SavePromptConfigurationRequest;
use App\Models\PromptConfiguration;
use App\Mvp\Audit\Services\AuditLogger;
use App\Mvp\Communications\Services\PromptConfigurationNamer;
use App\Mvp\Identity\MvpUser;
use App\Mvp\Support\MvpStateService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

/**
* Preset di prompt riutilizzabili (UC-19): indipendenti da una generazione
* effettiva, servono solo a precompilare il form con testo/tono/stile gia'
* usati in passato.
*/
class PromptConfigurationController
{
use ResolvesActor;

public function store(
SavePromptConfigurationRequest $request,
PromptConfigurationNamer $namer,
AuditLogger $audit,
MvpStateService $state,
): JsonResponse {
$actor = $this->actor($request);
$validated = $request->validated();

$configuration = PromptConfiguration::create([
'tenant_id' => $actor->tenantId,
'created_by' => $actor->id,
'name' => $namer->resolve($actor->tenantId, $validated['name'] ?? null),
'prompt' => $validated['prompt'],
'tone' => $validated['tone'],
'style' => $validated['style'],
]);

$audit->record(
'mvp-prompt-configuration-saved',
$actor,
'prompt_configuration',
(string) $configuration->id,
['name' => $configuration->name],
$request,
);

return response()->json([
'message' => 'Configurazione salvata.',
'configuration' => $state->promptConfiguration($configuration),
'state' => $state->forActor($actor),
], 201);
}

/**
* @throws AuthorizationException
*/
public function destroy(
Request $request,
PromptConfiguration $promptConfiguration,
AuditLogger $audit,
MvpStateService $state,
): JsonResponse {
$actor = $this->actor($request);
$this->assertOwnership($promptConfiguration, $actor);

$promptConfiguration->delete();

$audit->record(
'mvp-prompt-configuration-deleted',
$actor,
'prompt_configuration',
(string) $promptConfiguration->id,
[],
$request,
);

return response()->json([
'message' => 'Configurazione eliminata.',
'state' => $state->forActor($actor),
]);
}

/**
* @throws AuthorizationException
*/
private function assertOwnership(PromptConfiguration $configuration, MvpUser $actor): void
{
if ($configuration->tenant_id !== $actor->tenantId) {
throw new AuthorizationException('Prompt configuration is outside the authenticated tenant scope.');
}
}
}
4 changes: 2 additions & 2 deletions app/Http/Requests/GenerateCommunicationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@

class GenerateCommunicationRequest extends FormRequest
{
private const TONES = [
public const TONES = [
'Chiaro e diretto',
'Più istituzionale',
'Più sintetico',
'Empatico',
'Tecnico',
];

private const STYLES = [
public const STYLES = [
'Testo informativo',
'Avviso operativo',
'Aggiornamento breve',
Expand Down
Loading
Loading