From 5123d62589cafb1328f851e19e0331cb2ed11d86 Mon Sep 17 00:00:00 2001 From: Angela Maule Date: Sun, 2 Aug 2026 13:20:49 +0200 Subject: [PATCH 01/27] feat: salvataggio bozze nello storico e configurazioni di prompt riutilizzabili Implementa UC-9 (salvataggio esplicito della bozza generata nello storico, prima automatico) e UC-19 (salvataggio/riuso/eliminazione di configurazioni di prompt con nome, etichettatura automatica su nome vuoto o duplicato). Estende i guard di modificabilita'/rigenerabilita' in modo coerente (bloccati solo dallo scarto, non dal salvataggio in storico), allinea la doc tecnica al comportamento reale. Co-Authored-By: Claude Sonnet 5 --- .../Api/V1/CommunicationController.php | 56 ++++- .../Api/V1/CommunicationCoverController.php | 2 + .../V1/Concerns/AuthorizesCommunications.php | 12 + .../Api/V1/PromptConfigurationController.php | 97 ++++++++ .../Requests/GenerateCommunicationRequest.php | 4 +- .../SavePromptConfigurationRequest.php | 28 +++ app/Models/PromptConfiguration.php | 31 +++ .../Services/PromptConfigurationNamer.php | 38 +++ app/Mvp/Support/MvpStateService.php | 35 ++- .../src/api/generated/model/assistantState.ts | 2 + .../frontend/src/api/generated/model/index.ts | 5 + .../generated/model/promptConfiguration.ts | 17 ++ .../model/savePromptConfigurationRequest.ts | 25 ++ .../savePromptConfigurationRequestStyle.ts | 16 ++ .../savePromptConfigurationRequestTone.ts | 18 ++ .../model/savePromptConfigurationResponse.ts | 15 ++ apps/frontend/src/api/generated/mvp-api.ts | 110 +++++++++ .../app/core/state/mvp-state.store.spec.ts | 13 +- .../src/app/core/state/mvp-state.store.ts | 1 + .../features/assistant/assistant-page.spec.ts | 108 +++++++- .../app/features/assistant/assistant-page.ts | 230 +++++++++++++++++- .../communication-generator-panel.css | 39 +++ .../communication-generator-panel.spec.ts | 75 ++++++ .../communication-generator-panel.ts | 103 +++++++- .../generated-communication-preview.spec.ts | 30 ++- .../generated-communication-preview.ts | 68 ++++-- .../assistant/data/assistant.service.spec.ts | 10 + .../assistant/data/assistant.service.ts | 22 ++ .../factories/PromptConfigurationFactory.php | 29 +++ ...000_create_prompt_configurations_table.php | 31 +++ docs/IMPLEMENTATION_OVERVIEW.md | 39 ++- docs/mvp-scope.md | 18 +- openapi/v1/alittlebyte-mvp-api.yaml | 115 ++++++++- routes/api.php | 14 ++ tests/Feature/MvpAppRoutesTest.php | 128 +++++++++- tests/Feature/OpenApiContractTest.php | 31 +++ tests/Feature/PromptConfigurationTest.php | 148 +++++++++++ 37 files changed, 1688 insertions(+), 75 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/PromptConfigurationController.php create mode 100644 app/Http/Requests/SavePromptConfigurationRequest.php create mode 100644 app/Models/PromptConfiguration.php create mode 100644 app/Mvp/Communications/Services/PromptConfigurationNamer.php create mode 100644 apps/frontend/src/api/generated/model/promptConfiguration.ts create mode 100644 apps/frontend/src/api/generated/model/savePromptConfigurationRequest.ts create mode 100644 apps/frontend/src/api/generated/model/savePromptConfigurationRequestStyle.ts create mode 100644 apps/frontend/src/api/generated/model/savePromptConfigurationRequestTone.ts create mode 100644 apps/frontend/src/api/generated/model/savePromptConfigurationResponse.ts create mode 100644 database/factories/PromptConfigurationFactory.php create mode 100644 database/migrations/2026_08_01_000000_create_prompt_configurations_table.php create mode 100644 tests/Feature/PromptConfigurationTest.php diff --git a/app/Http/Controllers/Api/V1/CommunicationController.php b/app/Http/Controllers/Api/V1/CommunicationController.php index e3c347c5..1ac76805 100644 --- a/app/Http/Controllers/Api/V1/CommunicationController.php +++ b/app/Http/Controllers/Api/V1/CommunicationController.php @@ -18,7 +18,6 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; -use Illuminate\Validation\ValidationException; /** * Ciclo di vita della bozza: creazione, modifica manuale, nuova variante, scarto ed eliminazione definitiva. @@ -28,9 +27,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 { @@ -39,7 +39,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.'%'); @@ -117,11 +117,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(); @@ -166,6 +162,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 */ diff --git a/app/Http/Controllers/Api/V1/CommunicationCoverController.php b/app/Http/Controllers/Api/V1/CommunicationCoverController.php index 39cd0321..959835d3 100644 --- a/app/Http/Controllers/Api/V1/CommunicationCoverController.php +++ b/app/Http/Controllers/Api/V1/CommunicationCoverController.php @@ -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'); @@ -72,6 +73,7 @@ public function removeCoverImage( ): JsonResponse { $actor = $this->actor($request); $this->assertCommunicationOwnership($communication, $actor); + $this->assertCommunicationIsEditable($communication); $covers->remove($communication); diff --git a/app/Http/Controllers/Api/V1/Concerns/AuthorizesCommunications.php b/app/Http/Controllers/Api/V1/Concerns/AuthorizesCommunications.php index 2b84c5e1..9cf71ecb 100644 --- a/app/Http/Controllers/Api/V1/Concerns/AuthorizesCommunications.php +++ b/app/Http/Controllers/Api/V1/Concerns/AuthorizesCommunications.php @@ -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( diff --git a/app/Http/Controllers/Api/V1/PromptConfigurationController.php b/app/Http/Controllers/Api/V1/PromptConfigurationController.php new file mode 100644 index 00000000..b04647ba --- /dev/null +++ b/app/Http/Controllers/Api/V1/PromptConfigurationController.php @@ -0,0 +1,97 @@ +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.'); + } + } +} diff --git a/app/Http/Requests/GenerateCommunicationRequest.php b/app/Http/Requests/GenerateCommunicationRequest.php index 9a325ca4..801321ea 100644 --- a/app/Http/Requests/GenerateCommunicationRequest.php +++ b/app/Http/Requests/GenerateCommunicationRequest.php @@ -8,7 +8,7 @@ class GenerateCommunicationRequest extends FormRequest { - private const TONES = [ + public const TONES = [ 'Chiaro e diretto', 'Più istituzionale', 'Più sintetico', @@ -16,7 +16,7 @@ class GenerateCommunicationRequest extends FormRequest 'Tecnico', ]; - private const STYLES = [ + public const STYLES = [ 'Testo informativo', 'Avviso operativo', 'Aggiornamento breve', diff --git a/app/Http/Requests/SavePromptConfigurationRequest.php b/app/Http/Requests/SavePromptConfigurationRequest.php new file mode 100644 index 00000000..e278fca8 --- /dev/null +++ b/app/Http/Requests/SavePromptConfigurationRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['nullable', 'string', 'max:150'], + 'prompt' => ['required', 'string', 'min:12', 'max:5000'], + 'tone' => ['required', 'string', Rule::in(GenerateCommunicationRequest::TONES)], + 'style' => ['required', 'string', Rule::in(GenerateCommunicationRequest::STYLES)], + ]; + } +} diff --git a/app/Models/PromptConfiguration.php b/app/Models/PromptConfiguration.php new file mode 100644 index 00000000..0a17dfac --- /dev/null +++ b/app/Models/PromptConfiguration.php @@ -0,0 +1,31 @@ +nameExists($tenantId, $trimmed)) { + return $trimmed; + } + + $counter = 1; + + while ($this->nameExists($tenantId, "Senza nome ({$counter})")) { + $counter++; + } + + return "Senza nome ({$counter})"; + } + + private function nameExists(string $tenantId, string $name): bool + { + return PromptConfiguration::query() + ->where('tenant_id', $tenantId) + ->where('name', $name) + ->exists(); + } +} diff --git a/app/Mvp/Support/MvpStateService.php b/app/Mvp/Support/MvpStateService.php index d120ab79..c7ce6dff 100644 --- a/app/Mvp/Support/MvpStateService.php +++ b/app/Mvp/Support/MvpStateService.php @@ -5,6 +5,7 @@ use App\Models\Communication; use App\Models\ExtractedData; use App\Models\OriginalDocument; +use App\Models\PromptConfiguration; use App\Models\SubDocument; use App\Mvp\Communications\Enums\CommunicationStatus; use App\Mvp\Documents\Enums\ReviewStatus; @@ -38,14 +39,22 @@ public function assistantState(MvpUser $actor): array $drafts = (clone $baseQuery)->where('status', CommunicationStatus::Draft)->count(); $rated = (clone $baseQuery)->whereNotNull('rating')->count(); $averageRating = (clone $baseQuery)->whereNotNull('rating')->avg('rating'); - // Una bozza scartata (UC-7) resta tracciata (audit, metrica Prometheus per - // stato) ma non deve piu' comparire nell'area di lavoro dell'operatore: - // e' li' che l'utente si aspetta di vederla sparire, non solo etichettata. + // Una bozza entra nello storico solo dopo un salvataggio esplicito + // (UC-9): finche' resta draft, o dopo uno scarto (UC-7), non deve + // comparire qui, e' l'operatore a decidere cosa fissare nello storico. $history = (clone $baseQuery) - ->where('status', '!=', CommunicationStatus::Discarded) + ->where('status', CommunicationStatus::Approved) ->latest() ->limit(10) ->get(); + // Preset di prompt salvati (UC-19): elenco limitato, non filtrabile, + // pensato per un riuso rapido dal form di generazione, non come + // archivio ricercabile. + $promptConfigurations = PromptConfiguration::query() + ->where('tenant_id', $actor->tenantId) + ->latest() + ->limit(20) + ->get(); return [ // La `key` e' l'identificativo stabile: la label e' testo di @@ -62,6 +71,24 @@ public function assistantState(MvpUser $actor): array ], ], 'history' => $history->map(fn ($communication) => $this->communication($communication))->values()->all(), + 'promptConfigurations' => $promptConfigurations->map(fn ($configuration) => $this->promptConfiguration($configuration))->values()->all(), + ]; + } + + /** + * @return array + */ + public function promptConfiguration(PromptConfiguration $configuration): array + { + return [ + 'id' => $configuration->id, + 'name' => $configuration->name, + 'prompt' => $configuration->prompt, + 'tone' => $configuration->tone, + 'style' => $configuration->style, + // ISO, non formattata per la lettura: serve anche a filtrare per + // data lato frontend (vedi formatDateForDisplay in assistant-page). + 'createdAt' => $configuration->created_at?->format('Y-m-d'), ]; } diff --git a/apps/frontend/src/api/generated/model/assistantState.ts b/apps/frontend/src/api/generated/model/assistantState.ts index b759ad27..f0415d1e 100644 --- a/apps/frontend/src/api/generated/model/assistantState.ts +++ b/apps/frontend/src/api/generated/model/assistantState.ts @@ -7,8 +7,10 @@ */ import type { Communication } from './communication'; import type { Metric } from './metric'; +import type { PromptConfiguration } from './promptConfiguration'; export interface AssistantState { metrics: Metric[]; history: Communication[]; + promptConfigurations: PromptConfiguration[]; } diff --git a/apps/frontend/src/api/generated/model/index.ts b/apps/frontend/src/api/generated/model/index.ts index d0e0e1de..c75e57bd 100644 --- a/apps/frontend/src/api/generated/model/index.ts +++ b/apps/frontend/src/api/generated/model/index.ts @@ -29,8 +29,13 @@ export * from './listMvpDocumentsParams'; export * from './metric'; export * from './mvpState'; export * from './notFoundResponse'; +export * from './promptConfiguration'; export * from './rateCommunicationRequest'; export * from './rateCommunicationResponse'; +export * from './savePromptConfigurationRequest'; +export * from './savePromptConfigurationRequestStyle'; +export * from './savePromptConfigurationRequestTone'; +export * from './savePromptConfigurationResponse'; export * from './startCommunicationGenerationResponse'; export * from './subDocument'; export * from './subDocumentReviewStatus'; diff --git a/apps/frontend/src/api/generated/model/promptConfiguration.ts b/apps/frontend/src/api/generated/model/promptConfiguration.ts new file mode 100644 index 00000000..5cd2dc79 --- /dev/null +++ b/apps/frontend/src/api/generated/model/promptConfiguration.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.18.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ + +export interface PromptConfiguration { + id: number; + name: string; + prompt: string; + tone: string; + style: string; + /** @nullable */ + createdAt?: string | null; +} diff --git a/apps/frontend/src/api/generated/model/savePromptConfigurationRequest.ts b/apps/frontend/src/api/generated/model/savePromptConfigurationRequest.ts new file mode 100644 index 00000000..3dcba42b --- /dev/null +++ b/apps/frontend/src/api/generated/model/savePromptConfigurationRequest.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v8.18.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ +import type { SavePromptConfigurationRequestStyle } from './savePromptConfigurationRequestStyle'; +import type { SavePromptConfigurationRequestTone } from './savePromptConfigurationRequestTone'; + +export interface SavePromptConfigurationRequest { + /** + * Nome identificativo scelto dal Redattore. Se vuoto o gia' in uso per il tenant, il sistema assegna un'etichetta progressiva (es. "Senza nome (1)") (UC-19). + * @maxLength 150 + * @nullable + */ + name?: string | null; + /** + * @minLength 12 + * @maxLength 5000 + */ + prompt: string; + tone: SavePromptConfigurationRequestTone; + style: SavePromptConfigurationRequestStyle; +} diff --git a/apps/frontend/src/api/generated/model/savePromptConfigurationRequestStyle.ts b/apps/frontend/src/api/generated/model/savePromptConfigurationRequestStyle.ts new file mode 100644 index 00000000..fee7e86d --- /dev/null +++ b/apps/frontend/src/api/generated/model/savePromptConfigurationRequestStyle.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.18.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ + +export type SavePromptConfigurationRequestStyle = typeof SavePromptConfigurationRequestStyle[keyof typeof SavePromptConfigurationRequestStyle]; + + +export const SavePromptConfigurationRequestStyle = { + Testo_informativo: 'Testo informativo', + Avviso_operativo: 'Avviso operativo', + Aggiornamento_breve: 'Aggiornamento breve', +} as const; diff --git a/apps/frontend/src/api/generated/model/savePromptConfigurationRequestTone.ts b/apps/frontend/src/api/generated/model/savePromptConfigurationRequestTone.ts new file mode 100644 index 00000000..2e905ff4 --- /dev/null +++ b/apps/frontend/src/api/generated/model/savePromptConfigurationRequestTone.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.18.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ + +export type SavePromptConfigurationRequestTone = typeof SavePromptConfigurationRequestTone[keyof typeof SavePromptConfigurationRequestTone]; + + +export const SavePromptConfigurationRequestTone = { + Chiaro_e_diretto: 'Chiaro e diretto', + Più_istituzionale: 'Più istituzionale', + Più_sintetico: 'Più sintetico', + Empatico: 'Empatico', + Tecnico: 'Tecnico', +} as const; diff --git a/apps/frontend/src/api/generated/model/savePromptConfigurationResponse.ts b/apps/frontend/src/api/generated/model/savePromptConfigurationResponse.ts new file mode 100644 index 00000000..fee106a8 --- /dev/null +++ b/apps/frontend/src/api/generated/model/savePromptConfigurationResponse.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.18.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ +import type { MvpState } from './mvpState'; +import type { PromptConfiguration } from './promptConfiguration'; + +export interface SavePromptConfigurationResponse { + message: string; + configuration: PromptConfiguration; + state: MvpState; +} diff --git a/apps/frontend/src/api/generated/mvp-api.ts b/apps/frontend/src/api/generated/mvp-api.ts index d7d24bfe..bfc93efd 100644 --- a/apps/frontend/src/api/generated/mvp-api.ts +++ b/apps/frontend/src/api/generated/mvp-api.ts @@ -36,6 +36,8 @@ import type { MvpState, RateCommunicationRequest, RateCommunicationResponse, + SavePromptConfigurationRequest, + SavePromptConfigurationResponse, StartCommunicationGenerationResponse, UpdateCommunicationRequest, UpdateCommunicationResponse, @@ -268,6 +270,77 @@ export class AlittlebyteMVPAPIService { ); } +/** + * @summary Save the current prompt configuration for later reuse (UC-19) + */ + saveMvpPromptConfiguration(savePromptConfigurationRequest: SavePromptConfigurationRequest, options?: HttpClientBodyOptions): Observable; + saveMvpPromptConfiguration(savePromptConfigurationRequest: SavePromptConfigurationRequest, options?: HttpClientEventOptions): Observable>; + saveMvpPromptConfiguration(savePromptConfigurationRequest: SavePromptConfigurationRequest, options?: HttpClientResponseOptions): Observable>; + saveMvpPromptConfiguration( + savePromptConfigurationRequest: SavePromptConfigurationRequest, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `/api/v1/prompt-configurations`, + savePromptConfigurationRequest,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `/api/v1/prompt-configurations`, + savePromptConfigurationRequest,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.post( + `/api/v1/prompt-configurations`, + savePromptConfigurationRequest,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + +/** + * @summary Permanently delete a saved prompt configuration + */ + deleteMvpPromptConfiguration(promptConfiguration: number, options?: HttpClientBodyOptions): Observable; + deleteMvpPromptConfiguration(promptConfiguration: number, options?: HttpClientEventOptions): Observable>; + deleteMvpPromptConfiguration(promptConfiguration: number, options?: HttpClientResponseOptions): Observable>; + deleteMvpPromptConfiguration( + promptConfiguration: number, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.delete( + `/api/v1/prompt-configurations/${promptConfiguration}`,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.delete( + `/api/v1/prompt-configurations/${promptConfiguration}`,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.delete( + `/api/v1/prompt-configurations/${promptConfiguration}`,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + /** * @summary Update the title and body of a draft communication */ @@ -380,6 +453,43 @@ export class AlittlebyteMVPAPIService { ); } +/** + * @summary Save a communication draft to history, fixing it as no longer editable or regenerable + */ + saveMvpCommunication(communication: number, options?: HttpClientBodyOptions): Observable; + saveMvpCommunication(communication: number, options?: HttpClientEventOptions): Observable>; + saveMvpCommunication(communication: number, options?: HttpClientResponseOptions): Observable>; + saveMvpCommunication( + communication: number, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `/api/v1/communications/${communication}/save`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `/api/v1/communications/${communication}/save`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.post( + `/api/v1/communications/${communication}/save`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + /** * @summary Discard a communication draft */ diff --git a/apps/frontend/src/app/core/state/mvp-state.store.spec.ts b/apps/frontend/src/app/core/state/mvp-state.store.spec.ts index 4166298c..8e47e7be 100644 --- a/apps/frontend/src/app/core/state/mvp-state.store.spec.ts +++ b/apps/frontend/src/app/core/state/mvp-state.store.spec.ts @@ -6,7 +6,7 @@ import type { MvpState, SubDocument } from "../../../api/generated/model"; function stateWith(assistantMetrics: MvpState["assistant"]["metrics"], copilotMetrics: MvpState["copilot"]["metrics"]): MvpState { return { - assistant: { metrics: assistantMetrics, history: [] }, + assistant: { metrics: assistantMetrics, history: [], promptConfigurations: [] }, copilot: { metrics: copilotMetrics, documents: [] } } as MvpState; } @@ -55,10 +55,21 @@ describe("MvpStateStore", () => { expect(store.state()).toBeNull(); expect(store.documents()).toEqual([]); expect(store.history()).toEqual([]); + expect(store.promptConfigurations()).toEqual([]); expect(store.assistantMetrics()).toEqual([]); expect(store.copilotMetrics()).toEqual([]); }); + it("espone le configurazioni di prompt salvate (UC-19)", () => { + const state = stateWith([], []); + state.assistant.promptConfigurations = [ + { id: 1, name: "Ferie estive", prompt: "Un prompt qualsiasi", tone: "Empatico", style: "Comunicato" } + ]; + store.setState(state); + + expect(store.promptConfigurations()).toEqual(state.assistant.promptConfigurations); + }); + it("carica lo stato una sola volta e aggiorna loading ed errore", () => { const state = stateWith([{ key: "assistant.drafts", value: 3, label: "Bozze" }], []); getMvpState.mockReturnValue(of(state)); diff --git a/apps/frontend/src/app/core/state/mvp-state.store.ts b/apps/frontend/src/app/core/state/mvp-state.store.ts index 260a43ab..e64a2c9d 100644 --- a/apps/frontend/src/app/core/state/mvp-state.store.ts +++ b/apps/frontend/src/app/core/state/mvp-state.store.ts @@ -26,6 +26,7 @@ export class MvpStateStore { readonly documents = computed(() => this._state()?.copilot.documents ?? []); readonly history = computed(() => this._state()?.assistant.history ?? []); + readonly promptConfigurations = computed(() => this._state()?.assistant.promptConfigurations ?? []); readonly assistantMetrics = computed(() => this._state()?.assistant.metrics ?? []); readonly copilotMetrics = computed(() => this._state()?.copilot.metrics ?? []); diff --git a/apps/frontend/src/app/features/assistant/assistant-page.spec.ts b/apps/frontend/src/app/features/assistant/assistant-page.spec.ts index 18d3eab9..97aa4058 100644 --- a/apps/frontend/src/app/features/assistant/assistant-page.spec.ts +++ b/apps/frontend/src/app/features/assistant/assistant-page.spec.ts @@ -1,7 +1,7 @@ import { signal } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { of, throwError } from "rxjs"; -import type { Communication } from "../../../api/generated/model"; +import type { Communication, PromptConfiguration } from "../../../api/generated/model"; import { MvpStateStore } from "../../core/state/mvp-state.store"; import type { CommunicationDraftForm, GeneratedDraft } from "./assistant.model"; import { AssistantPage } from "./assistant-page"; @@ -38,11 +38,13 @@ describe("AssistantPage", () => { style: "Testo informativo" }; let history: ReturnType>; + let promptConfigurations: ReturnType>; let assistant: Record; let animation: jest.SpyInstance; beforeEach(() => { history = signal([]); + promptConfigurations = signal([]); assistant = { searchCommunications: jest.fn(() => of([])), generate: jest.fn(), @@ -52,6 +54,9 @@ describe("AssistantPage", () => { update: jest.fn(), removeCoverImage: jest.fn(), discard: jest.fn(), + saveToHistory: jest.fn(), + saveConfiguration: jest.fn(), + deleteConfiguration: jest.fn(), deleteFromHistory: jest.fn() }; animation = jest.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { @@ -60,7 +65,10 @@ describe("AssistantPage", () => { }); TestBed.configureTestingModule({ providers: [ - { provide: MvpStateStore, useValue: { history, error: signal(null) } }, + { + provide: MvpStateStore, + useValue: { history, error: signal(null), promptConfigurations } + }, { provide: AssistantService, useValue: assistant } ] }); @@ -297,6 +305,102 @@ describe("AssistantPage", () => { expect(page["previewDraft"]()).not.toBeNull(); }); + it("salva una bozza nello storico (UC-9) o mantiene lo stato in caso di errore", () => { + const page = createPage(); + page["saveToHistory"](); + expect(assistant["saveToHistory"]).not.toHaveBeenCalled(); + + setDraft(page); + assistant["saveToHistory"].mockReturnValue( + of({ message: "Bozza salvata nello storico.", communication: communication({ status: "Approvata", statusValue: "approved" }) }) + ); + page["saveToHistory"](); + expect(page["status"]()).toBe("Bozza salvata nello storico."); + expect(page["previewDraft"]()?.status).toBe("Approvata"); + expect(page["isSavingToHistory"]()).toBe(false); + + setDraft(page); + assistant["saveToHistory"].mockReturnValue(throwError(() => new Error("salvataggio fallito"))); + page["saveToHistory"](); + expect(page["status"]()).toBe("salvataggio fallito"); + }); + + it("salva la configurazione del prompt o segnala l'errore (UC-19)", () => { + const page = createPage(); + assistant["saveConfiguration"].mockReturnValue(of({ message: "Configurazione salvata." })); + + page["saveConfiguration"]({ prompt: "Un prompt qualsiasi lungo abbastanza", tone: "Tecnico", style: "Avviso operativo" }); + + expect(page["status"]()).toBe("Configurazione salvata."); + expect(page["isSavingConfiguration"]()).toBe(false); + + assistant["saveConfiguration"].mockReturnValue(throwError(() => new Error("salvataggio config fallito"))); + page["saveConfiguration"]({ prompt: "Un prompt qualsiasi lungo abbastanza", tone: "Tecnico", style: "Avviso operativo" }); + + expect(page["saveConfigurationError"]()).toBe("salvataggio config fallito"); + }); + + it("filtra le configurazioni salvate con gli stessi filtri dello storico", () => { + promptConfigurations.set([ + { id: 1, name: "Ferie estive", prompt: "Avviso sulle ferie estive", tone: "Empatico", style: "Avviso operativo", createdAt: "2026-03-04" }, + { id: 2, name: "Manutenzione", prompt: "Comunicazione tecnica di manutenzione", tone: "Tecnico", style: "Testo informativo", createdAt: "2026-05-01" } + ]); + const page = createPage(); + + expect(page["filteredPromptConfigurations"]()).toHaveLength(2); + + page["activeFilters"].set({ keyword: "ferie" }); + expect(page["filteredPromptConfigurations"]().map((c) => c.id)).toEqual([1]); + + page["activeFilters"].set({ tone: "Tecnico" }); + expect(page["filteredPromptConfigurations"]().map((c) => c.id)).toEqual([2]); + + page["activeFilters"].set({ style: "Avviso operativo" }); + expect(page["filteredPromptConfigurations"]().map((c) => c.id)).toEqual([1]); + + page["activeFilters"].set({ date: "2026-05-01" }); + expect(page["filteredPromptConfigurations"]().map((c) => c.id)).toEqual([2]); + + page["activeFilters"].set({ keyword: "nessuna corrispondenza" }); + expect(page["filteredPromptConfigurations"]()).toEqual([]); + }); + + it("riusa una configurazione salvata senza chiamare il backend (UC-19)", () => { + const page = createPage(); + + page["useConfiguration"]({ + id: 1, + name: "Ferie estive", + prompt: "Prompt della configurazione salvata", + tone: "Empatico", + style: "Avviso operativo" + }); + + expect(page["prefillPayload"]()).toEqual({ + prompt: "Prompt della configurazione salvata", + tone: "Empatico", + style: "Avviso operativo" + }); + expect(assistant["generate"]).not.toHaveBeenCalled(); + }); + + it("elimina una configurazione salvata o segnala l'errore", () => { + const page = createPage(); + page["confirmingConfigDeleteId"].set(3); + assistant["deleteConfiguration"].mockReturnValue(of({ message: "Configurazione eliminata." })); + + page["deleteConfiguration"](3); + + expect(page["status"]()).toBe("Configurazione eliminata."); + expect(page["confirmingConfigDeleteId"]()).toBeNull(); + expect(page["isDeletingConfig"]()).toBe(false); + + assistant["deleteConfiguration"].mockReturnValue(throwError(() => new Error("eliminazione config fallita"))); + page["deleteConfiguration"](3); + + expect(page["status"]()).toBe("eliminazione config fallita"); + }); + it("elimina dallo storico e pulisce le anteprime collegate", () => { const page = createPage(); setDraft(page); diff --git a/apps/frontend/src/app/features/assistant/assistant-page.ts b/apps/frontend/src/app/features/assistant/assistant-page.ts index 44242465..9fb8bc7e 100644 --- a/apps/frontend/src/app/features/assistant/assistant-page.ts +++ b/apps/frontend/src/app/features/assistant/assistant-page.ts @@ -4,7 +4,14 @@ import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { LucideTrash2 } from "@lucide/angular"; import { debounceTime, distinctUntilChanged, finalize } from "rxjs"; import { AssistantService, type CommunicationFilters } from "./data/assistant.service"; -import type { Communication, UpdateCommunicationRequest } from "../../../api/generated/model"; +import type { + Communication, + GenerateCommunicationRequestStyle, + GenerateCommunicationRequestTone, + PromptConfiguration, + SavePromptConfigurationRequest, + UpdateCommunicationRequest +} from "../../../api/generated/model"; import { MvpStateStore } from "../../core/state/mvp-state.store"; import { getApiErrorMessage } from "../../core/errors/api-error"; import { EmptyStateComponent } from "../../shared/components/empty-state/empty-state"; @@ -12,7 +19,7 @@ import { ErrorStateComponent } from "../../shared/components/error-state/error-s import { SectionComponent } from "../../layout/section/section"; import { StatusBadgeComponent } from "../../shared/components/status-badge/status-badge"; import { ButtonComponent } from "../../shared/components/button/button"; -import { formatFallback } from "../../shared/util/formatters"; +import { formatDateForDisplay, formatFallback } from "../../shared/util/formatters"; import { CommunicationGeneratorPanelComponent } from "./components/communication-generator-panel"; import { GeneratedCommunicationPreviewComponent } from "./components/generated-communication-preview"; import { communicationStyles, communicationTones } from "./assistant.model"; @@ -48,13 +55,19 @@ import type { [isGenerating]="isGenerating()" [status]="status()" [phase]="phase()" + [promptConfigurations]="promptConfigurations()" + [isSavingConfiguration]="isSavingConfiguration()" + [saveConfigurationError]="saveConfigurationError()" + [prefill]="prefillPayload()" (generate)="generate($event)" + (saveConfiguration)="saveConfiguration($event)" /> @@ -104,6 +118,63 @@ import type { + @if (filteredPromptConfigurations().length) { +
+ Configurazioni salvate + @for (configuration of filteredPromptConfigurations(); track configuration.id) { +
+
+ + {{ configuration.name }} + {{ formatDateForDisplay(configuration.createdAt) }} + +
+ + @if (confirmingConfigDeleteId() !== configuration.id) { + + } +
+
+ @if (confirmingConfigDeleteId() === configuration.id) { +
+

Eliminare definitivamente questa configurazione salvata?

+
+ + +
+
+ } +
+ } +
+ } + @if (filteredCommunications().length) { @for (communication of filteredCommunications(); track communication.id) {
@@ -168,6 +239,48 @@ import type { styleUrls: ["./components/communication-status-card.css", "../overview/overview-page.css"], styles: [ ` + .saved-configs { + display: grid; + gap: var(--mvp-space-2); + margin-bottom: var(--mvp-space-4); + } + + .saved-configs-label { + color: var(--mvp-muted); + font-size: var(--mvp-font-sm); + font-weight: 800; + } + + .saved-config { + display: grid; + gap: var(--mvp-space-2); + padding: var(--mvp-space-2) var(--mvp-space-3); + border: 1px solid var(--mvp-border); + border-radius: var(--mvp-radius); + background: var(--mvp-surface-muted); + } + + .saved-config-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--mvp-space-3); + } + + .saved-config-actions { + display: flex; + align-items: center; + gap: var(--mvp-space-2); + } + + .saved-config-date { + margin-left: var(--mvp-space-2); + color: var(--mvp-muted); + font-size: var(--mvp-font-sm); + font-weight: 400; + } + .filters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto; @@ -216,8 +329,11 @@ export class AssistantPage { ); protected readonly isUpdatingCover = signal(false); protected readonly isDiscarding = signal(false); + protected readonly isSavingToHistory = signal(false); protected readonly confirmingDeleteId = signal(null); protected readonly isDeletingHistoryItem = signal(false); + protected readonly confirmingConfigDeleteId = signal(null); + protected readonly isDeletingConfig = signal(false); protected readonly isRating = signal(false); protected readonly rateError = signal(null); protected readonly status = signal("In attesa di istruzioni."); @@ -225,7 +341,41 @@ export class AssistantPage { protected readonly latestDraft = signal(null); protected readonly isSavingDraft = signal(false); protected readonly saveDraftError = signal(null); + protected readonly isSavingConfiguration = signal(false); + protected readonly saveConfigurationError = signal(null); + protected readonly prefillPayload = signal(null); + protected readonly promptConfigurations = computed(() => this.store.promptConfigurations()); + /** Le configurazioni salvate condividono gli stessi filtri dello storico contenuti. */ + protected readonly filteredPromptConfigurations = computed(() => { + const filters = this.activeFilters(); + const keyword = filters.keyword?.trim().toLowerCase(); + + return this.promptConfigurations().filter((configuration) => { + if ( + keyword && + !configuration.name.toLowerCase().includes(keyword) && + !configuration.prompt.toLowerCase().includes(keyword) + ) { + return false; + } + + if (filters.tone && configuration.tone !== filters.tone) { + return false; + } + + if (filters.style && configuration.style !== filters.style) { + return false; + } + + if (filters.date && configuration.createdAt !== filters.date) { + return false; + } + + return true; + }); + }); protected readonly formatFallback = formatFallback; + protected readonly formatDateForDisplay = formatDateForDisplay; protected readonly tones = communicationTones; protected readonly styles = communicationStyles; @@ -457,6 +607,82 @@ export class AssistantPage { }); } + protected saveToHistory(): void { + const draft = this.previewDraft(); + + if (!draft) { + return; + } + + this.isSavingToHistory.set(true); + this.status.set("Salvataggio nello storico in corso."); + + this.assistant + .saveToHistory(draft.id) + .pipe(finalize(() => this.isSavingToHistory.set(false))) + .subscribe({ + next: (response) => { + this.status.set(response.message); + this.latestDraft.set(this.toDraft(response.communication)); + this.selectedDraftId.set(response.communication.id); + }, + error: (error: unknown) => { + this.status.set(getApiErrorMessage(error, "Salvataggio nello storico non disponibile.")); + } + }); + } + + /** Salva la configurazione corrente del prompt nello storico (UC-19). */ + protected saveConfiguration(payload: SavePromptConfigurationRequest): void { + this.saveConfigurationError.set(null); + this.isSavingConfiguration.set(true); + + this.assistant + .saveConfiguration(payload) + .pipe(finalize(() => this.isSavingConfiguration.set(false))) + .subscribe({ + next: (response) => { + this.status.set(response.message); + }, + error: (error: unknown) => { + this.saveConfigurationError.set( + getApiErrorMessage(error, "Salvataggio della configurazione non disponibile.") + ); + } + }); + } + + /** Riusa una configurazione di prompt salvata (UC-19): solo il form, nessuna chiamata al backend. */ + protected useConfiguration(configuration: PromptConfiguration): void { + this.prefillPayload.set({ + prompt: configuration.prompt, + tone: configuration.tone as GenerateCommunicationRequestTone, + style: configuration.style as GenerateCommunicationRequestStyle + }); + this.scrollTo("assistant-compose"); + } + + protected deleteConfiguration(configurationId: number): void { + this.isDeletingConfig.set(true); + + this.assistant + .deleteConfiguration(configurationId) + .pipe( + finalize(() => { + this.isDeletingConfig.set(false); + this.confirmingConfigDeleteId.set(null); + }) + ) + .subscribe({ + next: (response) => { + this.status.set(response.message); + }, + error: (error: unknown) => { + this.status.set(getApiErrorMessage(error, "Eliminazione della configurazione non disponibile.")); + } + }); + } + protected deleteHistoryItem(communicationId: number): void { this.isDeletingHistoryItem.set(true); diff --git a/apps/frontend/src/app/features/assistant/components/communication-generator-panel.css b/apps/frontend/src/app/features/assistant/components/communication-generator-panel.css index 145d2f62..1b68cbe1 100644 --- a/apps/frontend/src/app/features/assistant/components/communication-generator-panel.css +++ b/apps/frontend/src/app/features/assistant/components/communication-generator-panel.css @@ -25,6 +25,45 @@ color: var(--mvp-muted); } +.config-actions { + display: flex; + flex-wrap: wrap; + gap: var(--mvp-space-2); + align-items: center; +} + +.field { + display: grid; + gap: var(--mvp-space-2); + flex-basis: 100%; +} + +.field span { + color: var(--mvp-muted); + font-size: var(--mvp-font-sm); + font-weight: 800; +} + +.field input { + width: 100%; + padding: var(--mvp-space-3); + border: 1px solid var(--mvp-border); + border-radius: var(--mvp-radius); + background: var(--mvp-surface-muted); + color: var(--mvp-text); +} + +.warning { + flex-basis: 100%; + margin: 0; + padding: var(--mvp-space-2) var(--mvp-space-3); + border: 1px solid color-mix(in srgb, var(--mvp-warning) 50%, transparent); + border-radius: var(--mvp-radius); + background: color-mix(in srgb, var(--mvp-warning) 14%, var(--mvp-surface)); + color: var(--mvp-text); + font-size: var(--mvp-font-sm); +} + @media (max-width: 640px) { .fieldRow { grid-template-columns: 1fr; diff --git a/apps/frontend/src/app/features/assistant/components/communication-generator-panel.spec.ts b/apps/frontend/src/app/features/assistant/components/communication-generator-panel.spec.ts index 3df91460..e32746d5 100644 --- a/apps/frontend/src/app/features/assistant/components/communication-generator-panel.spec.ts +++ b/apps/frontend/src/app/features/assistant/components/communication-generator-panel.spec.ts @@ -40,6 +40,18 @@ describe("CommunicationGeneratorPanelComponent", () => { expect(fixture.componentInstance["form"].controls.prompt.touched).toBe(true); }); + it("chiude il modulo di salvataggio configurazione se si genera una nuova bozza invece di confermare", () => { + const fixture = render(); + const component = fixture.componentInstance; + component["isConfiguringName"].set(true); + component["configNameControl"].setValue("Nome non confermato"); + + component["submit"](); + + expect(component["isConfiguringName"]()).toBe(false); + expect(component["configNameControl"].value).toBe(""); + }); + it("riflette generazione, fase e stato negli input", () => { const element = render({ isGenerating: true, phase: "generating-text", status: "Testo in corso" }) .nativeElement as HTMLElement; @@ -55,4 +67,67 @@ describe("CommunicationGeneratorPanelComponent", () => { expect(element.querySelector("mvp-generation-progress")).toBeNull(); }); + + it("salva la configurazione corrente con o senza nome (UC-19)", () => { + const fixture = render(); + const component = fixture.componentInstance; + const saved: unknown[] = []; + component.saveConfiguration.subscribe((value) => saved.push(value)); + + component["isConfiguringName"].set(true); + component["configNameControl"].setValue(" La mia config "); + component["confirmSaveConfiguration"](); + + component["isConfiguringName"].set(true); + component["configNameControl"].setValue(" "); + component["confirmSaveConfiguration"](); + + expect(saved).toEqual([ + expect.objectContaining({ name: "La mia config", tone: "Chiaro e diretto", style: "Testo informativo" }), + expect.objectContaining({ name: undefined }) + ]); + }); + + it("non salva la configurazione se il prompt e' insufficiente", () => { + const fixture = render(); + const component = fixture.componentInstance; + const saved: unknown[] = []; + component.saveConfiguration.subscribe((value) => saved.push(value)); + component["form"].controls.prompt.setValue("breve"); + + component["confirmSaveConfiguration"](); + + expect(saved).toEqual([]); + expect(component["form"].controls.prompt.touched).toBe(true); + }); + + it("richiude il modulo del nome quando l'elenco delle configurazioni salvate cambia", () => { + const fixture = render({ promptConfigurations: [] }); + const component = fixture.componentInstance; + component["isConfiguringName"].set(true); + + fixture.componentRef.setInput("promptConfigurations", [ + { id: 1, name: "Prima config", prompt: "Un prompt qualsiasi", tone: "Chiaro e diretto", style: "Testo informativo" } + ]); + fixture.detectChanges(); + + expect(component["isConfiguringName"]()).toBe(false); + }); + + it("applica i valori di prefill ricevuti dal genitore (riuso di una configurazione salvata, UC-19)", () => { + const fixture = render(); + + fixture.componentRef.setInput("prefill", { + prompt: "Prompt riusato da una configurazione salvata", + tone: "Tecnico", + style: "Avviso operativo" + }); + fixture.detectChanges(); + + expect(fixture.componentInstance["form"].getRawValue()).toEqual({ + prompt: "Prompt riusato da una configurazione salvata", + tone: "Tecnico", + style: "Avviso operativo" + }); + }); }); diff --git a/apps/frontend/src/app/features/assistant/components/communication-generator-panel.ts b/apps/frontend/src/app/features/assistant/components/communication-generator-panel.ts index 4554fd43..e25bb031 100644 --- a/apps/frontend/src/app/features/assistant/components/communication-generator-panel.ts +++ b/apps/frontend/src/app/features/assistant/components/communication-generator-panel.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; +import { ChangeDetectionStrategy, Component, effect, input, output, signal } from "@angular/core"; import { GenerationProgressComponent } from "./generation-progress"; import type { CommunicationGenerationPhase } from "../assistant.model"; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; @@ -14,7 +14,9 @@ import { } from "../assistant.model"; import type { GenerateCommunicationRequestStyle, - GenerateCommunicationRequestTone + GenerateCommunicationRequestTone, + PromptConfiguration, + SavePromptConfigurationRequest } from "../../../../api/generated/model"; @Component({ @@ -54,6 +56,50 @@ import type { }

{{ status() }}

+ +
+ @if (isConfiguringName()) { + + @if (saveConfigurationError()) { + + } + + + } @else { + + } +
`, styleUrl: "./communication-generator-panel.css" @@ -62,10 +108,18 @@ export class CommunicationGeneratorPanelComponent { readonly isGenerating = input.required(); readonly status = input.required(); readonly phase = input.required(); + readonly promptConfigurations = input([]); + readonly isSavingConfiguration = input(false); + readonly saveConfigurationError = input(null); + /** Valori da caricare nel form da fuori (riuso di una configurazione salvata, UC-19). */ + readonly prefill = input(null); readonly generate = output(); + readonly saveConfiguration = output(); protected readonly tones = communicationTones; protected readonly styles = communicationStyles; + protected readonly isConfiguringName = signal(false); + protected readonly configNameControl = new FormControl("", { nonNullable: true }); protected readonly form = new FormGroup({ prompt: new FormControl( "Scrivi una comunicazione interna per informare i dipendenti che la nuova area documentale NEXUM e disponibile. Spiega cosa cambia, dove trovare i documenti e perche la consultazione diventa piu semplice.", @@ -75,12 +129,57 @@ export class CommunicationGeneratorPanelComponent { style: new FormControl("Testo informativo", { nonNullable: true }) }); + constructor() { + // Riuso di una configurazione salvata (UC-19): il genitore spinge i + // valori da fuori, qui li applichiamo al form. + effect(() => { + const values = this.prefill(); + + if (values) { + this.form.setValue(values); + } + }); + + // Il salvataggio (UC-19) e' confermato dal genitore tramite lo stato + // aggiornato: quando l'elenco cambia (nuova configurazione salvata) il + // modulo del nome si richiude da solo. In caso di errore l'elenco non + // cambia, quindi il modulo resta aperto con il messaggio visibile. + effect(() => { + this.promptConfigurations(); + this.isConfiguringName.set(false); + this.configNameControl.reset(""); + }); + } + protected submit(): void { if (this.form.invalid) { this.form.markAllAsTouched(); return; } + // Avviare una nuova generazione chiude un eventuale salvataggio + // configurazione lasciato a meta': non ha senso restasse aperto sopra + // una bozza che nel frattempo cambia. + this.isConfiguringName.set(false); + this.configNameControl.reset(""); this.generate.emit(this.form.getRawValue()); } + + protected confirmSaveConfiguration(): void { + if (this.form.controls.prompt.invalid) { + this.form.controls.prompt.markAsTouched(); + return; + } + + const raw = this.form.getRawValue(); + const name = this.configNameControl.value.trim(); + + this.saveConfiguration.emit({ + name: name === "" ? undefined : name, + prompt: raw.prompt, + tone: raw.tone, + style: raw.style + }); + } + } diff --git a/apps/frontend/src/app/features/assistant/components/generated-communication-preview.spec.ts b/apps/frontend/src/app/features/assistant/components/generated-communication-preview.spec.ts index 09b00ef2..e3a3ae67 100644 --- a/apps/frontend/src/app/features/assistant/components/generated-communication-preview.spec.ts +++ b/apps/frontend/src/app/features/assistant/components/generated-communication-preview.spec.ts @@ -48,6 +48,8 @@ describe("GeneratedCommunicationPreviewComponent", () => { expect(component["isCoverPending"](draft({ coverStatus: "processing" }))).toBe(true); expect(component["isCoverPending"](draft({ coverStatus: "failed" }))).toBe(false); expect(component["isDiscarded"](draft({ status: "Scartata" }))).toBe(true); + expect(component["isApproved"](draft({ status: "Approvata" }))).toBe(true); + expect(component["isApproved"](draft({ status: "Bozza" }))).toBe(false); expect(component["isReadyForPreview"](draft())).toBe(true); expect(component["isReadyForPreview"](draft({ generationStatus: "processing" }))).toBe(false); expect(component["isReadyForPreview"](draft({ status: "Scartata" }))).toBe(false); @@ -243,17 +245,41 @@ describe("GeneratedCommunicationPreviewComponent", () => { expect(component["form"].controls.body.touched).toBe(true); }); - it("propaga le azioni di copertina, rigenerazione e scarto", () => { + it("propaga le azioni di copertina, rigenerazione, salvataggio e scarto", () => { const fixture = render(draft({ coverImageUrl: "/cover.png", coverStatus: "ready" })); const events: string[] = []; fixture.componentInstance.removeCover.subscribe(() => events.push("remove")); fixture.componentInstance.regenerate.subscribe(() => events.push("regenerate")); fixture.componentInstance.discard.subscribe(() => events.push("discard")); + fixture.componentInstance.saveToHistory.subscribe(() => events.push("saveToHistory")); fixture.componentInstance.removeCover.emit(); fixture.componentInstance.regenerate.emit(); fixture.componentInstance.discard.emit(); + fixture.componentInstance.saveToHistory.emit(); - expect(events).toEqual(["remove", "regenerate", "discard"]); + expect(events).toEqual(["remove", "regenerate", "discard", "saveToHistory"]); + }); + + it("nasconde solo il salvataggio una volta che la bozza e' nello storico, restando modificabile e rigenerabile", () => { + const fixture = render(draft({ status: "Approvata", statusValue: "approved" })); + const element = fixture.nativeElement as HTMLElement; + const labels = Array.from(element.querySelectorAll("button")).map((button) => button.textContent?.trim()); + + expect(labels).not.toContain("Salva nello storico"); + expect(labels).toContain("Rigenera bozza"); + expect(labels).toContain("Modifica"); + }); + + it("nasconde modifica, rigenerazione e azioni copertina per una bozza scartata", () => { + const fixture = render(draft({ status: "Scartata", statusValue: "draft" })); + const element = fixture.nativeElement as HTMLElement; + const labels = Array.from(element.querySelectorAll("button")).map((button) => button.textContent?.trim()); + + expect(labels).not.toContain("Salva nello storico"); + expect(labels).not.toContain("Rigenera bozza"); + expect(labels).not.toContain("Modifica"); + expect(labels).not.toContain("Cambia immagine"); + expect(labels).not.toContain("Rimuovi immagine"); }); }); diff --git a/apps/frontend/src/app/features/assistant/components/generated-communication-preview.ts b/apps/frontend/src/app/features/assistant/components/generated-communication-preview.ts index 3f1978b7..2d905f8b 100644 --- a/apps/frontend/src/app/features/assistant/components/generated-communication-preview.ts +++ b/apps/frontend/src/app/features/assistant/components/generated-communication-preview.ts @@ -56,28 +56,30 @@ import {

{{ currentDraft.coverError }}

} -
- - - -
+ @if (!isDiscarded(currentDraft)) { +
+ + + +
+ } @if (saveError()) {

{{ saveError() }}

} @@ -105,7 +107,7 @@ import { {{ isSaving() ? "Salvataggio" : "Salva" }} - } @else if (currentDraft.statusValue === "draft") { + } @else if (!isDiscarded(currentDraft)) {
@if (!isDiscarded(currentDraft)) { + @if (!isApproved(currentDraft)) { + + } + } + @if (!isDiscarded(currentDraft)) { @if (isConfirmingDiscard()) {

Sei sicuro di voler scartare questa bozza? Non sara' piu' modificabile ne' rigenerabile. @@ -236,6 +250,7 @@ export class GeneratedCommunicationPreviewComponent { readonly isUpdatingCover = input(false); readonly isGenerating = input(false); readonly isDiscarding = input(false); + readonly isSavingToHistory = input(false); readonly isRating = input(false); readonly rateError = input(null); readonly isSaving = input(false); @@ -244,6 +259,7 @@ export class GeneratedCommunicationPreviewComponent { readonly removeCover = output(); readonly regenerate = output(); readonly discard = output(); + readonly saveToHistory = output(); readonly rate = output(); readonly saveRequested = output<{ communicationId: number; payload: UpdateCommunicationRequest }>(); @@ -325,6 +341,10 @@ export class GeneratedCommunicationPreviewComponent { return draft.status === "Scartata"; } + protected isApproved(draft: GeneratedDraft): boolean { + return draft.status === "Approvata"; + } + protected isReadyForPreview(draft: GeneratedDraft): boolean { return draft.generationStatus === "completed" && draft.status !== "Scartata" && Boolean(draft.previewUrl); } diff --git a/apps/frontend/src/app/features/assistant/data/assistant.service.spec.ts b/apps/frontend/src/app/features/assistant/data/assistant.service.spec.ts index 933eb1c5..7faae34e 100644 --- a/apps/frontend/src/app/features/assistant/data/assistant.service.spec.ts +++ b/apps/frontend/src/app/features/assistant/data/assistant.service.spec.ts @@ -51,6 +51,9 @@ describe("AssistantService", () => { updateMvpCommunicationCoverImage: jest.fn(), removeMvpCommunicationCoverImage: jest.fn(), discardMvpCommunication: jest.fn(), + saveMvpCommunication: jest.fn(), + saveMvpPromptConfiguration: jest.fn(), + deleteMvpPromptConfiguration: jest.fn(), deleteMvpCommunication: jest.fn(), rateMvpCommunication: jest.fn(), updateMvpCommunication: jest.fn(), @@ -199,6 +202,13 @@ describe("AssistantService", () => { ["updateCoverImage", "updateMvpCommunicationCoverImage", [7, expect.any(File)]], ["removeCoverImage", "removeMvpCommunicationCoverImage", [7]], ["discard", "discardMvpCommunication", [7]], + ["saveToHistory", "saveMvpCommunication", [7]], + [ + "saveConfiguration", + "saveMvpPromptConfiguration", + [{ prompt: "Un prompt qualsiasi lungo abbastanza", tone: "Tecnico", style: "Avviso operativo" }] + ], + ["deleteConfiguration", "deleteMvpPromptConfiguration", [1]], ["deleteFromHistory", "deleteMvpCommunication", [7]], ["rate", "rateMvpCommunication", [7, { rating: 4 }]], ["update", "updateMvpCommunication", [7, { title: "Titolo", body: "Corpo aggiornato" }]] diff --git a/apps/frontend/src/app/features/assistant/data/assistant.service.ts b/apps/frontend/src/app/features/assistant/data/assistant.service.ts index 76303047..eeb6af32 100644 --- a/apps/frontend/src/app/features/assistant/data/assistant.service.ts +++ b/apps/frontend/src/app/features/assistant/data/assistant.service.ts @@ -8,6 +8,8 @@ import type { MvpState, RateCommunicationRequest, RateCommunicationResponse, + SavePromptConfigurationRequest, + SavePromptConfigurationResponse, StartCommunicationGenerationResponse, UpdateCommunicationRequest, UpdateCommunicationResponse @@ -167,6 +169,13 @@ export class AssistantService { .pipe(tap((response) => this.store.setState(response.state))); } + /** Fissa la bozza nello storico (UC-9): da qui non e' piu' modificabile ne' rigenerabile. */ + saveToHistory(communicationId: number): Observable { + return this.api + .saveMvpCommunication(communicationId) + .pipe(tap((response) => this.store.setState(response.state))); + } + deleteFromHistory(communicationId: number): Observable { return this.api .deleteMvpCommunication(communicationId) @@ -185,6 +194,19 @@ export class AssistantService { .pipe(tap((response) => this.store.setState(response.state))); } + /** Salva la configurazione corrente del prompt nello storico (UC-19). */ + saveConfiguration(payload: SavePromptConfigurationRequest): Observable { + return this.api + .saveMvpPromptConfiguration(payload) + .pipe(tap((response) => this.store.setState(response.state))); + } + + deleteConfiguration(configurationId: number): Observable { + return this.api + .deleteMvpPromptConfiguration(configurationId) + .pipe(tap((response) => this.store.setState(response.state))); + } + /** * Storico filtrato (UC-15..UC-18): i criteri viaggiano al backend, che resta * l'unica autorita' sui dati. Le bozze scartate restano escluse, come nello diff --git a/database/factories/PromptConfigurationFactory.php b/database/factories/PromptConfigurationFactory.php new file mode 100644 index 00000000..7c228d31 --- /dev/null +++ b/database/factories/PromptConfigurationFactory.php @@ -0,0 +1,29 @@ + + */ +class PromptConfigurationFactory extends Factory +{ + protected $model = PromptConfiguration::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => 'mvp-local-tenant', + 'created_by' => 'mvp-local-user', + 'name' => fake()->unique()->words(2, true), + 'prompt' => fake()->paragraph(), + 'tone' => fake()->randomElement(['Chiaro e diretto', 'Più istituzionale', 'Più sintetico', 'Empatico', 'Tecnico']), + 'style' => fake()->randomElement(['Testo informativo', 'Avviso operativo', 'Aggiornamento breve']), + ]; + } +} diff --git a/database/migrations/2026_08_01_000000_create_prompt_configurations_table.php b/database/migrations/2026_08_01_000000_create_prompt_configurations_table.php new file mode 100644 index 00000000..fcdffea3 --- /dev/null +++ b/database/migrations/2026_08_01_000000_create_prompt_configurations_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('tenant_id', 120)->default('mvp-local-tenant'); + $table->string('created_by', 120)->nullable(); + $table->string('name', 150); + $table->text('prompt'); + $table->string('tone', 100); + $table->string('style', 100); + $table->timestamps(); + + $table->index(['tenant_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('prompt_configurations'); + } +}; diff --git a/docs/IMPLEMENTATION_OVERVIEW.md b/docs/IMPLEMENTATION_OVERVIEW.md index 4193e53e..37e327a1 100644 --- a/docs/IMPLEMENTATION_OVERVIEW.md +++ b/docs/IMPLEMENTATION_OVERVIEW.md @@ -351,25 +351,41 @@ e la stessa forma di risposta `{items, total, page, perPage}`: | Endpoint | Controller | Filtri | |---|---|---| -| `GET /api/v1/communications` | `CommunicationController::index` (`ListCommunicationsRequest`) | parola chiave sul prompt, tono, stile, giorno di creazione (UC-15..UC-18). Esclude le bozze scartate, come lo storico | +| `GET /api/v1/communications` | `CommunicationController::index` (`ListCommunicationsRequest`) | parola chiave sul prompt, tono, stile, giorno di creazione (UC-15..UC-18). Mostra solo le bozze salvate esplicitamente nello storico (stato `approved`, UC-9): draft e scartate non compaiono | | `GET /api/v1/documents` | `DocumentController::index` (`ListDocumentsRequest`) | nome/cognome/azienda, stato di invio, soglia di confidenza sopra o sotto, mese e anno (UC-35..UC-38) | Gli elementi hanno la stessa forma degli oggetti esposti nello stato: la SPA non conosce due rappresentazioni dello stesso dato. -### 6.5 Revisione, modifica e valutazione delle bozze (implementato) +### 6.5 Revisione, modifica, salvataggio e valutazione delle bozze (implementato) `routes/api.php` espone il ciclo completo sulla bozza: `PUT /api/v1/communications/{communication}` -per la modifica manuale di titolo e testo (consentita solo in stato `draft`), -`POST .../regenerate` per una nuova variante, `POST .../discard` per lo scarto, -`DELETE .../{communication}` per l'eliminazione dallo storico e `POST .../rating` per la +per la modifica manuale di titolo e testo, `POST .../regenerate` per una nuova variante, +`POST .../save` per il salvataggio esplicito nello storico (UC-9), `POST .../discard` per lo +scarto, `DELETE .../{communication}` per l'eliminazione definitiva e `POST .../rating` per la valutazione 1-5 con commento opzionale, registrabile una sola volta per generazione. Ogni mutazione passa da `assertCommunicationOwnership()` e viene registrata nell'audit trail. -Lo stato `approved` resta **predisposizione deliberata e documentata**, come l'identità SES: -esiste nell'enum `CommunicationStatus` e nel vincolo CHECK della migrazione, ma nessun codice lo -assegna e non c'è endpoint per la transizione. Il perimetro non prevede un flusso di -approvazione (vedi [`mvp-scope.md`](mvp-scope.md)): non è codice dimenticato e non va "completato" -senza una decisione di scope. + +Lo stato `approved` (enum `CommunicationStatus`, vincolo CHECK nella migrazione) **non è più solo +predisposizione**: `CommunicationController::save()` esegue la transizione `draft → approved` +(UC-9). Da quel momento la bozza compare nello storico filtrabile (§6.4.1); resta comunque +modificabile e rigenerabile come una draft, finché non viene scartata +(`assertCommunicationIsEditable()`/`assertCommunicationCanRegenerate()` bloccano solo lo stato +`discarded`, non `approved`) — il salvataggio decide cosa compare nello storico, non blocca il +contenuto. Il modello dei permessi resta quello descritto in [`mvp-scope.md`](mvp-scope.md): non +c'è un flusso di approvazione multi-ruolo, è l'operatore stesso a decidere cosa archiviare. + +**Preset di prompt riutilizzabili (UC-19, implementato)**: `POST /api/v1/prompt-configurations` +salva testo/tono/stile del form corrente come preset con nome libero (`PromptConfigurationController`, +tabella `prompt_configurations`); se il nome è vuoto o già in uso per il tenant, +`PromptConfigurationNamer` assegna un'etichetta progressiva ("Senza nome (1)", "(2)", ...). +`DELETE /api/v1/prompt-configurations/{promptConfiguration}` la rimuove definitivamente. I preset +(fino a 20 per tenant) viaggiano dentro `assistant.promptConfigurations` nello stato applicativo, +non tramite un endpoint di lista dedicato: il riuso di un preset è puramente lato frontend, popola +i campi del form (`prefill` sul pannello di generazione) senza mutare nulla lato server. Il riuso +dei parametri di una generazione già archiviata (distinto da UC-19, UC-20 nel catalogo dei casi +d'uso) è stato valutato e scartato: sulle bozze già salvate esistono già Modifica e Rigenera, un +terzo comando ridondante avrebbe solo aggiunto confusione. ### 6.6 Invio comunicazioni / email (fuori scope, stato reinterpretato come scaricamento) @@ -397,7 +413,7 @@ Flag off → il task `textract.ocr` ritorna `enabled=false` e la pipeline proseg ## 7. Persistenza, stato e modello dati -Sei tabelle di dominio (`database/migrations/`): +Sette tabelle di dominio (`database/migrations/`): | Tabella | Chiavi/indici notevoli | Note | |---|---|---| @@ -407,6 +423,7 @@ Sei tabelle di dominio (`database/migrations/`): | `extracted_data` | FK **unique** cascade su sub_document | 1:1 con sotto-documento; confidence 0-100; campi destinatario `recipient_email`, `fiscal_code`, `employee_id` correggibili a mano | | `audit_events` | `(tenant_id, event_type)`, `(resource_type, resource_id)`, `created_at` | append-only (nessun `updated_at`), metadata JSON | | `workflow_tasks` | `task_token_hash` char(64) **unique**; `(subject_type, subject_id, task_type)`, `(status, task_type)` | tabella unica delle due pipeline, soggetto polimorfico (`original_document`/`communication`), input/output payload JSON, stati pending→running→succeeded/skipped/failed | +| `prompt_configurations` | `(tenant_id, name)` | preset di prompt riutilizzabili (UC-19): nome, testo, tono, stile; nessun vincolo UNIQUE sul nome, la de-duplicazione ("Senza nome (N)") è solo applicativa (`PromptConfigurationNamer`) | Gli stati applicativi sono enum PHP con cast Eloquent (`ProcessingStatus`, `SendStatus`, `CommunicationStatus`, `CommunicationGenerationStatus`, `CoverImageStatus`, `CoverImageSource`) duplicati come CHECK a livello DB: doppia difesa coerente. Le relazioni Eloquent rispecchiano le FK. diff --git a/docs/mvp-scope.md b/docs/mvp-scope.md index 06981aee..9022a28b 100644 --- a/docs/mvp-scope.md +++ b/docs/mvp-scope.md @@ -28,15 +28,24 @@ Incluso: - validazione del prompt; - persistenza della bozza generata (stato `draft`); - immagine di copertina generata dall'AI, con sostituzione manuale e rimozione; -- storico delle generazioni con riapertura dell'anteprima di una bozza selezionata; +- storico dei soli contenuti salvati (stato "Approvata"), con riapertura dell'anteprima di una + voce selezionata; una bozza non ancora salvata non vi compare (UC-9); - filtri dello storico per parola chiave, tono, stile e data (UC-15..UC-18); - modifica manuale persistente di titolo e testo della bozza, con annullamento prima del - salvataggio; la modifica è consentita solo finché la bozza è in stato `draft`; + salvataggio; consentita finché la bozza non è stata scartata; - richiesta di una nuova variante della bozza corrente, che sostituisce testo e copertina - mantenendo prompt, tono e stile (UC-6); + mantenendo prompt, tono e stile (UC-6); consentita anche dopo il salvataggio in storico; +- salvataggio esplicito della bozza nello storico (UC-9): solo a questo punto entra nello + storico (stato "Approvata"); resta comunque modificabile e rigenerabile come prima; - scarto della bozza corrente con conferma, che la esclude dallo storico attivo mantenendola tracciata come "Scartata" (UC-7); - eliminazione definitiva di un elemento dello storico, con conferma (UC-23); +- salvataggio della configurazione corrente del prompt (testo, tono, stile) come preset + riutilizzabile, con nome libero; se il nome è vuoto o già in uso per il tenant, il sistema + assegna un'etichetta progressiva ("Senza nome (1)", "(2)", ...); i preset compaiono nello + storico contenuti, filtrabili con gli stessi criteri delle bozze salvate (UC-19); +- riutilizzo di un preset salvato, che precompila il form senza avviare una nuova generazione; + non applicabile a una bozza già salvata, per cui restano modifica e rigenerazione diretta; - valutazione 1-5 stelle con commento qualitativo opzionale, una sola per generazione; - anteprima del documento finale impaginato, con marcatore di trasparenza "Creato da AI Assistant"; - esportazione del documento finale in PDF, con lo stesso marcatore di trasparenza; @@ -45,8 +54,7 @@ Incluso: In corso: -- aggiunta e rimozione di una generazione dai preferiti (UC-21, UC-22); -- salvataggio e riuso di una configurazione di prompt etichettata (UC-32 e logica di riuso). +- aggiunta e rimozione di una generazione dai preferiti (UC-21, UC-22). Fuori scope MVP: diff --git a/openapi/v1/alittlebyte-mvp-api.yaml b/openapi/v1/alittlebyte-mvp-api.yaml index 22117ed0..2f5b545d 100644 --- a/openapi/v1/alittlebyte-mvp-api.yaml +++ b/openapi/v1/alittlebyte-mvp-api.yaml @@ -113,6 +113,49 @@ paths: $ref: "#/components/responses/ValidationError" "502": $ref: "#/components/responses/UpstreamUnavailable" + /api/v1/prompt-configurations: + post: + summary: Save the current prompt configuration for later reuse (UC-19) + operationId: saveMvpPromptConfiguration + tags: [Communications] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SavePromptConfigurationRequest" + responses: + "201": + description: Prompt configuration saved to history. + content: + application/json: + schema: + $ref: "#/components/schemas/SavePromptConfigurationResponse" + "422": + $ref: "#/components/responses/ValidationError" + /api/v1/prompt-configurations/{promptConfiguration}: + delete: + summary: Permanently delete a saved prompt configuration + operationId: deleteMvpPromptConfiguration + tags: [Communications] + parameters: + - name: promptConfiguration + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + "200": + description: Prompt configuration deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteDocumentResponse" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /api/v1/communications/{communication}: put: summary: Update the title and body of a draft communication @@ -178,6 +221,26 @@ paths: $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/ValidationError" + /api/v1/communications/{communication}/save: + post: + summary: Save a communication draft to history, fixing it as no longer editable or regenerable + operationId: saveMvpCommunication + tags: [Communications] + parameters: + - $ref: "#/components/parameters/CommunicationId" + responses: + "200": + description: Communication saved to history. + content: + application/json: + schema: + $ref: "#/components/schemas/CommunicationMutationResponse" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/ValidationError" /api/v1/communications/{communication}/discard: post: summary: Discard a communication draft @@ -290,6 +353,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/ValidationError" /api/v1/communications/{communication}/preview: get: summary: Preview the final laid-out communication PDF @@ -725,7 +790,7 @@ components: $ref: "#/components/schemas/CopilotState" AssistantState: type: object - required: [metrics, history] + required: [metrics, history, promptConfigurations] properties: metrics: type: array @@ -735,6 +800,10 @@ components: type: array items: $ref: "#/components/schemas/Communication" + promptConfigurations: + type: array + items: + $ref: "#/components/schemas/PromptConfiguration" CopilotState: type: object required: [metrics, documents] @@ -774,6 +843,50 @@ components: style: type: string enum: [Testo informativo, Avviso operativo, Aggiornamento breve] + SavePromptConfigurationRequest: + type: object + required: [prompt, tone, style] + properties: + name: + type: [string, "null"] + maxLength: 150 + description: Nome identificativo scelto dal Redattore. Se vuoto o gia' in uso per il tenant, il sistema assegna un'etichetta progressiva (es. "Senza nome (1)") (UC-19). + prompt: + type: string + minLength: 12 + maxLength: 5000 + tone: + type: string + enum: [Chiaro e diretto, Più istituzionale, Più sintetico, Empatico, Tecnico] + style: + type: string + enum: [Testo informativo, Avviso operativo, Aggiornamento breve] + SavePromptConfigurationResponse: + type: object + required: [message, configuration, state] + properties: + message: + type: string + configuration: + $ref: "#/components/schemas/PromptConfiguration" + state: + $ref: "#/components/schemas/MvpState" + PromptConfiguration: + type: object + required: [id, name, prompt, tone, style] + properties: + id: + type: integer + name: + type: string + prompt: + type: string + tone: + type: string + style: + type: string + createdAt: + type: [string, "null"] StartCommunicationGenerationResponse: type: object required: [message, communicationId, streamUrl] diff --git a/routes/api.php b/routes/api.php index e33b48d4..7adb44cc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\DocumentController; use App\Http\Controllers\Api\V1\DocumentPreviewController; use App\Http\Controllers\Api\V1\DocumentReviewController; +use App\Http\Controllers\Api\V1\PromptConfigurationController; use App\Http\Controllers\Api\V1\SendMessageController; use App\Http\Controllers\Api\V1\StateController; use Illuminate\Support\Facades\Route; @@ -25,6 +26,14 @@ ->middleware('throttle:20,1') ->name('communications.generate'); + Route::post('/prompt-configurations', [PromptConfigurationController::class, 'store']) + ->middleware('throttle:20,1') + ->name('prompt-configurations.save'); + + Route::delete('/prompt-configurations/{promptConfiguration}', [PromptConfigurationController::class, 'destroy']) + ->whereNumber('promptConfiguration') + ->name('prompt-configurations.delete'); + Route::get('/communications/{communication}/stream', [CommunicationStreamController::class, 'stream']) ->whereNumber('communication') ->name('communications.stream'); @@ -34,6 +43,11 @@ ->middleware('throttle:20,1') ->name('communications.regenerate'); + Route::post('/communications/{communication}/save', [CommunicationController::class, 'save']) + ->whereNumber('communication') + ->middleware('throttle:20,1') + ->name('communications.save'); + Route::post('/communications/{communication}/discard', [CommunicationController::class, 'discard']) ->whereNumber('communication') ->middleware('throttle:20,1') diff --git a/tests/Feature/MvpAppRoutesTest.php b/tests/Feature/MvpAppRoutesTest.php index 3055a4fd..bada7176 100644 --- a/tests/Feature/MvpAppRoutesTest.php +++ b/tests/Feature/MvpAppRoutesTest.php @@ -887,9 +887,10 @@ function mvpRunWorkflowTask(OriginalDocument $document, string $taskType = 'bedr expect($communication->refresh()->generated_title)->not->toBe('Titolo'); }); -test('the communication index excludes discarded drafts, like the history', function () { +test('the communication index only returns communications saved to history, not drafts or discarded ones', function () { Communication::factory()->draft()->create(); Communication::factory()->discarded()->create(); + Communication::factory()->approved()->create(); $response = $this->getJson('/api/v1/communications')->assertOk(); @@ -897,12 +898,12 @@ function mvpRunWorkflowTask(OriginalDocument $document, string $taskType = 'bedr }); test('the communication index filters by keyword, tone and style', function () { - $target = Communication::factory()->draft()->create([ + $target = Communication::factory()->approved()->create([ 'prompt' => 'Comunicazione sulla nuova area documentale', 'tone' => 'Chiaro e diretto', 'style' => 'Testo informativo', ]); - Communication::factory()->draft()->create([ + Communication::factory()->approved()->create([ 'prompt' => 'Avviso ferie estive', 'tone' => 'Più istituzionale', 'style' => 'Comunicato', @@ -924,9 +925,9 @@ function mvpRunWorkflowTask(OriginalDocument $document, string $taskType = 'bedr }); test('the communication index filters by creation day', function () { - $old = Communication::factory()->draft()->create(); + $old = Communication::factory()->approved()->create(); $old->forceFill(['created_at' => '2025-03-04 10:00:00'])->save(); - Communication::factory()->draft()->create(); + Communication::factory()->approved()->create(); $this->getJson('/api/v1/communications?date=2025-03-04') ->assertOk() @@ -934,11 +935,11 @@ function mvpRunWorkflowTask(OriginalDocument $document, string $taskType = 'bedr ->assertJsonPath('items.0.id', $old->id); }); -test('the communication index only returns drafts of the caller tenant', function () { +test('the communication index only returns saved communications of the caller tenant', function () { config(['mvp.identity.mode' => 'trusted_headers']); - Communication::factory()->draft()->create(['tenant_id' => 'mvp-local-tenant']); - Communication::factory()->draft()->create(['tenant_id' => 'another-tenant']); + Communication::factory()->approved()->create(['tenant_id' => 'mvp-local-tenant']); + Communication::factory()->approved()->create(['tenant_id' => 'another-tenant']); $this->withHeaders([ 'Accept' => 'application/json', @@ -1193,6 +1194,19 @@ function mvpRunWorkflowTask(OriginalDocument $document, string $taskType = 'bedr Storage::disk('s3')->assertMissing($previousPath); }); +test('a discarded communication rejects a manual cover image upload', function () { + Storage::fake('s3'); + config(['mvp.communications.cover_disk' => 's3']); + + $communication = Communication::factory()->discarded()->coverReady()->create(); + + $this->withHeader('Accept', 'application/json') + ->post("/api/v1/communications/{$communication->id}/cover-image", [ + 'image' => UploadedFile::fake()->image('manual-cover.png', 1280, 720), + ]) + ->assertUnprocessable(); +}); + test('operator can download the cover image of a communication', function () { Storage::fake('s3'); config(['mvp.communications.cover_disk' => 's3']); @@ -1240,6 +1254,18 @@ function mvpRunWorkflowTask(OriginalDocument $document, string $taskType = 'bedr Storage::disk('s3')->assertMissing($path); }); +test('a discarded communication rejects a manual cover image removal', function () { + Storage::fake('s3'); + config(['mvp.communications.cover_disk' => 's3']); + + $communication = Communication::factory()->discarded()->coverReady()->create(); + Storage::disk('s3')->put($communication->cover_image_path, 'copertina'); + + $this->withHeader('Accept', 'application/json') + ->deleteJson("/api/v1/communications/{$communication->id}/cover-image") + ->assertUnprocessable(); +}); + test('manual cover upload rejects cross tenant access', function () { config(['mvp.identity.mode' => 'trusted_headers']); $communication = Communication::factory()->draft()->create(); @@ -1421,6 +1447,15 @@ function mvpRunWorkflowTask(OriginalDocument $document, string $taskType = 'bedr ->assertUnprocessable(); }); +test('a communication already saved to history can still be regenerated', function () { + mvpMockCommunicationWorkflowRegenerate($this); + + $communication = Communication::factory()->approved()->coverReady()->create(); + + $this->postJson("/api/v1/communications/{$communication->id}/regenerate") + ->assertAccepted(); +}); + test('regenerate endpoint rejects cross tenant access', function () { config(['mvp.identity.mode' => 'trusted_headers']); $communication = Communication::factory()->draft()->coverReady()->create(); @@ -1564,6 +1599,73 @@ function mvpRunWorkflowTask(OriginalDocument $document, string $taskType = 'bedr Storage::disk('s3')->assertExists($regenerated->cover_image_path); }); +test('operator can save a communication draft to history (UC-9)', function () { + $communication = Communication::factory()->draft()->coverReady()->create(); + + $this->postJson("/api/v1/communications/{$communication->id}/save") + ->assertOk() + ->assertJsonPath('message', 'Bozza salvata nello storico.') + ->assertJsonPath('communication.id', $communication->id) + ->assertJsonPath('communication.status', 'Approvata'); + + expect($communication->fresh()->status)->toBe(CommunicationStatus::Approved) + ->and(AuditEvent::query()->where('event_type', 'mvp-communication-saved')->count())->toBe(1); +}); + +test('a saved communication remains visible in the history returned to the operator', function () { + $communication = Communication::factory()->draft()->coverReady()->create(); + + $this->postJson("/api/v1/communications/{$communication->id}/save")->assertOk(); + + $response = $this->getJson('/api/v1/state')->assertOk(); + + $historyIds = collect($response->json('assistant.history'))->pluck('id'); + + expect($historyIds)->toContain($communication->id); +}); + +test('a communication already saved to history cannot be saved again', function () { + $communication = Communication::factory()->approved()->create(); + + $this->withHeader('Accept', 'application/json') + ->postJson("/api/v1/communications/{$communication->id}/save") + ->assertUnprocessable(); +}); + +test('a discarded communication cannot be saved to history', function () { + $communication = Communication::factory()->discarded()->create(); + + $this->withHeader('Accept', 'application/json') + ->postJson("/api/v1/communications/{$communication->id}/save") + ->assertUnprocessable(); +}); + +test('save endpoint rejects cross tenant access', function () { + config(['mvp.identity.mode' => 'trusted_headers']); + $communication = Communication::factory()->draft()->coverReady()->create(); + + $this->withHeaders([ + 'Accept' => 'application/json', + 'X-Mvp-User-Id' => 'operator-b', + 'X-Mvp-User-Email' => 'operator-b@example.test', + 'X-Mvp-Tenant-Id' => 'another-tenant', + 'X-Mvp-Roles' => 'mvp-operator', + ])->postJson("/api/v1/communications/{$communication->id}/save") + ->assertForbidden() + ->assertJsonPath('error.code', 'forbidden'); +}); + +test('a saved communication can still be edited', function () { + $communication = Communication::factory()->approved()->create(); + + $this->putJson("/api/v1/communications/{$communication->id}", [ + 'title' => 'Nuovo titolo', + 'body' => 'Nuovo corpo', + ]) + ->assertOk() + ->assertJsonPath('communication.title', 'Nuovo titolo'); +}); + test('operator can discard a communication draft', function () { $communication = Communication::factory()->draft()->coverReady()->create(); @@ -1578,7 +1680,7 @@ function mvpRunWorkflowTask(OriginalDocument $document, string $taskType = 'bedr }); test('a discarded communication no longer appears in the history returned to the operator', function () { - $communication = Communication::factory()->draft()->coverReady()->create(); + $communication = Communication::factory()->approved()->coverReady()->create(); $this->postJson("/api/v1/communications/{$communication->id}/discard")->assertOk(); @@ -1589,6 +1691,14 @@ function mvpRunWorkflowTask(OriginalDocument $document, string $taskType = 'bedr expect($historyIds)->not->toContain($communication->id); }); +test('a draft not yet saved does not appear in the history until UC-9 is completed', function () { + Communication::factory()->draft()->create(); + + $response = $this->getJson('/api/v1/state')->assertOk(); + + expect($response->json('assistant.history'))->toBeEmpty(); +}); + test('an already discarded communication cannot be discarded again', function () { $communication = Communication::factory()->discarded()->create(); diff --git a/tests/Feature/OpenApiContractTest.php b/tests/Feature/OpenApiContractTest.php index 4f7ad5fe..ce65d68d 100644 --- a/tests/Feature/OpenApiContractTest.php +++ b/tests/Feature/OpenApiContractTest.php @@ -3,6 +3,7 @@ use App\Models\Communication; use App\Models\ExtractedData; use App\Models\OriginalDocument; +use App\Models\PromptConfiguration; use App\Models\SubDocument; use App\Mvp\Communications\Services\CommunicationWorkflowService; use App\Mvp\Documents\Services\DocumentWorkflowService; @@ -72,6 +73,36 @@ function contractPdfUpload(): UploadedFile ->and($response->json('error.correlationId'))->toBeString(); }); +test('POST /api/v1/prompt-configurations rispetta il contratto OpenAPI', function () { + $response = $this->postJson('/api/v1/prompt-configurations', [ + 'name' => 'Comunicazione ferie', + 'prompt' => 'Avvisa il personale delle nuove ferie disponibili da prenotare.', + 'tone' => 'Chiaro e diretto', + 'style' => 'Testo informativo', + ])->assertCreated(); + + OpenApiSpec::assertResponseMatchesContract($response->json(), '/api/v1/prompt-configurations', 'post', '201'); +}); + +test('POST /api/v1/prompt-configurations con payload invalido rispetta il contratto per il 422', function () { + $response = $this->postJson('/api/v1/prompt-configurations', [])->assertUnprocessable(); + + OpenApiSpec::assertResponseMatchesContract($response->json(), '/api/v1/prompt-configurations', 'post', '422'); +}); + +test('DELETE /api/v1/prompt-configurations/{promptConfiguration} rispetta il contratto OpenAPI', function () { + $configuration = PromptConfiguration::factory()->create(); + + $response = $this->deleteJson("/api/v1/prompt-configurations/{$configuration->id}")->assertOk(); + + OpenApiSpec::assertResponseMatchesContract( + $response->json(), + '/api/v1/prompt-configurations/{promptConfiguration}', + 'delete', + '200', + ); +}); + test('POST /api/v1/communications/{communication}/cover-image rispetta il contratto OpenAPI', function () { Storage::fake('s3'); config(['mvp.communications.cover_disk' => 's3']); diff --git a/tests/Feature/PromptConfigurationTest.php b/tests/Feature/PromptConfigurationTest.php new file mode 100644 index 00000000..576b5e8e --- /dev/null +++ b/tests/Feature/PromptConfigurationTest.php @@ -0,0 +1,148 @@ +postJson('/api/v1/prompt-configurations', [ + 'name' => 'Comunicazione ferie', + 'prompt' => 'Avvisa il personale delle nuove ferie disponibili da prenotare.', + 'tone' => 'Chiaro e diretto', + 'style' => 'Testo informativo', + ]) + ->assertCreated() + ->assertJsonPath('message', 'Configurazione salvata.') + ->assertJsonPath('configuration.name', 'Comunicazione ferie') + ->assertJsonPath('state.assistant.promptConfigurations.0.name', 'Comunicazione ferie'); + + expect(PromptConfiguration::query()->where('name', 'Comunicazione ferie')->exists())->toBeTrue(); +}); + +test('saving with an empty name assigns a progressive default label', function () { + $this->postJson('/api/v1/prompt-configurations', [ + 'name' => '', + 'prompt' => 'Avvisa il personale delle nuove ferie disponibili da prenotare.', + 'tone' => 'Chiaro e diretto', + 'style' => 'Testo informativo', + ]) + ->assertCreated() + ->assertJsonPath('configuration.name', 'Senza nome (1)'); +}); + +test('saving with a name already in use for the tenant falls back to a default label', function () { + PromptConfiguration::factory()->create(['tenant_id' => 'mvp-local-tenant', 'name' => 'Comunicazione ferie']); + + $this->postJson('/api/v1/prompt-configurations', [ + 'name' => 'Comunicazione ferie', + 'prompt' => 'Avvisa il personale delle nuove ferie disponibili da prenotare.', + 'tone' => 'Chiaro e diretto', + 'style' => 'Testo informativo', + ]) + ->assertCreated() + ->assertJsonPath('configuration.name', 'Senza nome (1)'); +}); + +test('the progressive default label skips labels already taken', function () { + PromptConfiguration::factory()->create(['tenant_id' => 'mvp-local-tenant', 'name' => 'Senza nome (1)']); + PromptConfiguration::factory()->create(['tenant_id' => 'mvp-local-tenant', 'name' => 'Senza nome (2)']); + + $this->postJson('/api/v1/prompt-configurations', [ + 'prompt' => 'Avvisa il personale delle nuove ferie disponibili da prenotare.', + 'tone' => 'Chiaro e diretto', + 'style' => 'Testo informativo', + ]) + ->assertCreated() + ->assertJsonPath('configuration.name', 'Senza nome (3)'); +}); + +test('a duplicate name from another tenant does not trigger the fallback label', function () { + PromptConfiguration::factory()->create(['tenant_id' => 'another-tenant', 'name' => 'Comunicazione ferie']); + + $this->postJson('/api/v1/prompt-configurations', [ + 'name' => 'Comunicazione ferie', + 'prompt' => 'Avvisa il personale delle nuove ferie disponibili da prenotare.', + 'tone' => 'Chiaro e diretto', + 'style' => 'Testo informativo', + ]) + ->assertCreated() + ->assertJsonPath('configuration.name', 'Comunicazione ferie'); +}); + +test('an insufficient prompt is rejected when saving a configuration (UC-70)', function () { + $this->withHeader('Accept', 'application/json') + ->postJson('/api/v1/prompt-configurations', [ + 'name' => 'Config incompleta', + 'prompt' => 'Corto', + 'tone' => 'Chiaro e diretto', + 'style' => 'Testo informativo', + ]) + ->assertUnprocessable() + ->assertJsonPath('error.code', 'validation_failed'); + + expect(PromptConfiguration::query()->where('name', 'Config incompleta')->exists())->toBeFalse(); +}); + +test('an invalid tone or style is rejected when saving a configuration', function () { + $this->withHeader('Accept', 'application/json') + ->postJson('/api/v1/prompt-configurations', [ + 'prompt' => 'Avvisa il personale delle nuove ferie disponibili da prenotare.', + 'tone' => 'Tono inventato', + 'style' => 'Testo informativo', + ]) + ->assertUnprocessable(); +}); + +test('saving a configuration records an audit event', function () { + $this->postJson('/api/v1/prompt-configurations', [ + 'name' => 'Comunicazione ferie', + 'prompt' => 'Avvisa il personale delle nuove ferie disponibili da prenotare.', + 'tone' => 'Chiaro e diretto', + 'style' => 'Testo informativo', + ])->assertCreated(); + + expect(AuditEvent::query()->where('event_type', 'mvp-prompt-configuration-saved')->count())->toBe(1); +}); + +test('operator can permanently delete a saved prompt configuration', function () { + $configuration = PromptConfiguration::factory()->create(['tenant_id' => 'mvp-local-tenant']); + + $this->deleteJson("/api/v1/prompt-configurations/{$configuration->id}") + ->assertOk() + ->assertJsonPath('message', 'Configurazione eliminata.') + ->assertJsonStructure(['message', 'state']); + + expect(PromptConfiguration::query()->find($configuration->id))->toBeNull() + ->and(AuditEvent::query()->where('event_type', 'mvp-prompt-configuration-deleted')->count())->toBe(1); +}); + +test('deleting a prompt configuration removes it from the state payload', function () { + $configuration = PromptConfiguration::factory()->create(['tenant_id' => 'mvp-local-tenant']); + + $response = $this->deleteJson("/api/v1/prompt-configurations/{$configuration->id}")->assertOk(); + + $ids = collect($response->json('state.assistant.promptConfigurations'))->pluck('id'); + expect($ids)->not->toContain($configuration->id); +}); + +test('delete prompt configuration endpoint rejects cross tenant access', function () { + config(['mvp.identity.mode' => 'trusted_headers']); + $configuration = PromptConfiguration::factory()->create(['tenant_id' => 'mvp-local-tenant']); + + $this->withHeaders([ + 'Accept' => 'application/json', + 'X-Mvp-User-Id' => 'operator-b', + 'X-Mvp-User-Email' => 'operator-b@example.test', + 'X-Mvp-Tenant-Id' => 'another-tenant', + 'X-Mvp-Roles' => 'mvp-operator', + ])->deleteJson("/api/v1/prompt-configurations/{$configuration->id}") + ->assertForbidden() + ->assertJsonPath('error.code', 'forbidden'); + + expect(PromptConfiguration::query()->find($configuration->id))->not->toBeNull(); +}); + +test('deleting a nonexistent prompt configuration returns 404', function () { + $this->withHeader('Accept', 'application/json') + ->deleteJson('/api/v1/prompt-configurations/999999') + ->assertNotFound(); +}); From c22d6a6f2a2de6675b16911e28ff9641a375bf66 Mon Sep 17 00:00:00 2001 From: Angelica Date: Mon, 3 Aug 2026 10:05:13 +0200 Subject: [PATCH 02/27] implementazione caricamento con metadati --- .../Controllers/Api/V1/DocumentController.php | 8 +- app/Http/Requests/UploadDocumentRequest.php | 31 ++++ app/Models/OriginalDocument.php | 21 +++ .../Services/DocumentProcessingService.php | 88 ++++++++++- .../frontend/src/api/generated/model/index.ts | 1 + .../generated/model/uploadMvpDocumentBody.ts | 26 ++++ .../uploadMvpDocumentBodyDocumentType.ts | 23 +++ apps/frontend/src/api/generated/mvp-api.ts | 12 ++ .../components/document-upload-panel.css | 88 +++++++++++ .../components/document-upload-panel.spec.ts | 50 +++++- .../components/document-upload-panel.ts | 142 +++++++++++++++++- .../app/features/copilot/copilot-page.spec.ts | 7 +- .../src/app/features/copilot/copilot-page.ts | 6 +- .../data/document-workflow.service.spec.ts | 22 +++ .../copilot/data/document-workflow.service.ts | 95 +++++++----- ..._upload_metadata_to_original_documents.php | 31 ++++ docs/mvp-scope.md | 5 +- openapi/v1/alittlebyte-mvp-api.yaml | 26 ++++ tests/Feature/DocumentExtractionTest.php | 35 +++++ tests/Feature/MvpAppRoutesTest.php | 56 +++++++ 20 files changed, 705 insertions(+), 68 deletions(-) create mode 100644 apps/frontend/src/api/generated/model/uploadMvpDocumentBodyDocumentType.ts create mode 100644 database/migrations/2026_08_03_000000_add_manual_upload_metadata_to_original_documents.php diff --git a/app/Http/Controllers/Api/V1/DocumentController.php b/app/Http/Controllers/Api/V1/DocumentController.php index 7cb21f40..7abd4400 100644 --- a/app/Http/Controllers/Api/V1/DocumentController.php +++ b/app/Http/Controllers/Api/V1/DocumentController.php @@ -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, ); diff --git a/app/Http/Requests/UploadDocumentRequest.php b/app/Http/Requests/UploadDocumentRequest.php index 0edadfee..bcac7f70 100644 --- a/app/Http/Requests/UploadDocumentRequest.php +++ b/app/Http/Requests/UploadDocumentRequest.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Validation\Validator as ValidatorContract; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Process; +use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; use setasign\Fpdi\Fpdi; @@ -27,6 +28,36 @@ public function rules(): array return [ 'document' => ['required', 'file', 'mimetypes:application/pdf', 'max:'.$maxKilobytes], + // Metadati manuali: opzionali; se presenti vengono preservati sull'output AI. + 'documentType' => ['sometimes', 'nullable', 'string', Rule::in(UpdateExtractedDataRequest::DOCUMENT_TYPES)], + 'companyName' => ['sometimes', 'nullable', 'string', 'max:500'], + 'month' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:12'], + 'year' => ['sometimes', 'nullable', 'integer', 'min:1900', 'max:2100'], + ]; + } + + /** + * Metadati manuali da applicare dopo l'estrazione AI (null = lascia decidere all'AI). + * + * @return array{ + * document_type: ?string, + * company_name: ?string, + * reference_month: ?int, + * reference_year: ?int + * } + */ + public function manualMetadata(): array + { + $validated = $this->validated(); + + $documentType = isset($validated['documentType']) ? trim((string) $validated['documentType']) : ''; + $companyName = isset($validated['companyName']) ? trim((string) $validated['companyName']) : ''; + + return [ + 'document_type' => $documentType !== '' ? $documentType : null, + 'company_name' => $companyName !== '' ? $companyName : null, + 'reference_month' => isset($validated['month']) ? (int) $validated['month'] : null, + 'reference_year' => isset($validated['year']) ? (int) $validated['year'] : null, ]; } diff --git a/app/Models/OriginalDocument.php b/app/Models/OriginalDocument.php index 85c71bb6..87f4cdf5 100644 --- a/app/Models/OriginalDocument.php +++ b/app/Models/OriginalDocument.php @@ -15,6 +15,10 @@ * @property string|null $created_by * @property string $file_path * @property string $original_filename + * @property string|null $manual_document_type + * @property string|null $manual_company_name + * @property int|null $manual_reference_month + * @property int|null $manual_reference_year * @property ProcessingStatus $processing_status * @property string|null $error_message * @property string|null $s3_bucket @@ -38,6 +42,10 @@ class OriginalDocument extends Model 'created_by', 'file_path', 'original_filename', + 'manual_document_type', + 'manual_company_name', + 'manual_reference_month', + 'manual_reference_year', 'processing_status', 'error_message', 's3_bucket', @@ -60,6 +68,8 @@ protected function casts(): array { return [ 'processing_status' => ProcessingStatus::class, + 'manual_reference_month' => 'integer', + 'manual_reference_year' => 'integer', 'workflow_started_at' => 'datetime', 'workflow_completed_at' => 'datetime', 'workflow_failed_at' => 'datetime', @@ -68,6 +78,17 @@ protected function casts(): array ]; } + /** + * True se almeno un metadato manuale e' stato impostato in fase di upload. + */ + public function hasManualUploadMetadata(): bool + { + return $this->manual_document_type !== null + || $this->manual_company_name !== null + || $this->manual_reference_month !== null + || $this->manual_reference_year !== null; + } + /** * @return HasMany */ diff --git a/app/Mvp/Documents/Services/DocumentProcessingService.php b/app/Mvp/Documents/Services/DocumentProcessingService.php index 0e5e9f13..3a2d8350 100644 --- a/app/Mvp/Documents/Services/DocumentProcessingService.php +++ b/app/Mvp/Documents/Services/DocumentProcessingService.php @@ -30,9 +30,16 @@ public function __construct( ) {} /** + * @param array{ + * document_type?: ?string, + * company_name?: ?string, + * reference_month?: ?int, + * reference_year?: ?int + * } $manualMetadata + * * @throws \RuntimeException when the upload cannot be persisted to the configured disk. */ - public function storeUpload(UploadedFile $file, MvpUser $actor): OriginalDocument + public function storeUpload(UploadedFile $file, MvpUser $actor, array $manualMetadata = []): OriginalDocument { $path = $file->store('originals', $this->documentDisk()); @@ -42,16 +49,28 @@ public function storeUpload(UploadedFile $file, MvpUser $actor): OriginalDocumen $safeName = preg_replace('/[^\w.\-]/u', '_', $file->getClientOriginalName()) ?: 'documento.pdf'; - return $this->handleStoredFile($path, $safeName, $actor); + return $this->handleStoredFile($path, $safeName, $actor, $manualMetadata); } - public function handleStoredFile(string $path, string $filename, ?MvpUser $actor = null): OriginalDocument + /** + * @param array{ + * document_type?: ?string, + * company_name?: ?string, + * reference_month?: ?int, + * reference_year?: ?int + * } $manualMetadata + */ + public function handleStoredFile(string $path, string $filename, ?MvpUser $actor = null, array $manualMetadata = []): OriginalDocument { return OriginalDocument::create([ 'tenant_id' => $actor?->tenantId ?? 'mvp-local-tenant', 'created_by' => $actor?->id, 'file_path' => $path, 'original_filename' => $filename, + 'manual_document_type' => $manualMetadata['document_type'] ?? null, + 'manual_company_name' => $manualMetadata['company_name'] ?? null, + 'manual_reference_month' => $manualMetadata['reference_month'] ?? null, + 'manual_reference_year' => $manualMetadata['reference_year'] ?? null, 'processing_status' => ProcessingStatus::Pending, ]); } @@ -59,7 +78,9 @@ public function handleStoredFile(string $path, string $filename, ?MvpUser $actor public function extractAndSaveFields(SubDocument $subDocument): void { try { - $fields = $this->extractFields($subDocument); + $aiFields = $this->extractFields($subDocument); + // I metadati impostati in upload prevalgono sull'estrazione AI. + $fields = $this->applyManualMetadataOverrides($aiFields, $subDocument->originalDocument); // La confidenza effettiva non è l'auto-valutazione del modello (non // calibrata), ma un valore oggettivo: leggibilità OCR × completezza // dei campi chiave. L'output grezzo del modello resta in ai_payload. @@ -73,7 +94,7 @@ public function extractAndSaveFields(SubDocument $subDocument): void ['sub_document_id' => $subDocument->id], array_merge($fields, [ 'confidence_score' => $confidenceScore, - 'ai_payload' => $fields, + 'ai_payload' => $aiFields, ]), ); $this->metrics->recordDomainCounter('ai_extractions_total', [ @@ -470,6 +491,63 @@ private function extractFields(SubDocument $subDocument): array return $this->bedrock->extractFields($ocrText); } + /** + * Tipologia, azienda e mese/anno impostati in upload restano autoritativi. + * L'AI continua a produrre l'estrazione (e il payload grezzo), ma non sovrascrive + * i campi già dichiarati dal consulente. + * + * @param array{employee_first_name: ?string, employee_last_name: ?string, company_name: ?string, document_date: ?string, document_type: ?string, description: ?string, confidence_score: ?int} $fields + * @return array{employee_first_name: ?string, employee_last_name: ?string, company_name: ?string, document_date: ?string, document_type: ?string, description: ?string, confidence_score: ?int} + */ + private function applyManualMetadataOverrides(array $fields, ?OriginalDocument $original): array + { + if ($original === null || ! $original->hasManualUploadMetadata()) { + return $fields; + } + + if ($original->manual_document_type !== null) { + $fields['document_type'] = $original->manual_document_type; + } + + if ($original->manual_company_name !== null) { + $fields['company_name'] = $original->manual_company_name; + } + + $month = $original->manual_reference_month; + $year = $original->manual_reference_year; + + if ($month !== null && $year !== null) { + $fields['document_date'] = sprintf('%04d-%02d-01', $year, $month); + } elseif ($year !== null || $month !== null) { + $fields['document_date'] = $this->mergeManualDateWithAi($fields['document_date'] ?? null, $month, $year); + } + + return $fields; + } + + /** + * Completa mese o anno mancante usando la data AI quando disponibile. + */ + private function mergeManualDateWithAi(?string $aiDate, ?int $month, ?int $year): ?string + { + $aiYear = null; + $aiMonth = null; + + if (is_string($aiDate) && preg_match('/^(\d{4})-(\d{2})/', $aiDate, $matches) === 1) { + $aiYear = (int) $matches[1]; + $aiMonth = (int) $matches[2]; + } + + $resolvedYear = $year ?? $aiYear; + $resolvedMonth = $month ?? $aiMonth; + + if ($resolvedYear === null || $resolvedMonth === null) { + return $aiDate; + } + + return sprintf('%04d-%02d-01', $resolvedYear, $resolvedMonth); + } + /** * Random per-run boundary token used to delimit pages in the OCR text fed to * the classifier. Unguessable so it cannot collide with document content. diff --git a/apps/frontend/src/api/generated/model/index.ts b/apps/frontend/src/api/generated/model/index.ts index d0e0e1de..ef51d55f 100644 --- a/apps/frontend/src/api/generated/model/index.ts +++ b/apps/frontend/src/api/generated/model/index.ts @@ -44,6 +44,7 @@ export * from './updateSendMessageRequest'; export * from './updateSubDocumentReviewResponse'; export * from './uploadDocumentResponse'; export * from './uploadMvpDocumentBody'; +export * from './uploadMvpDocumentBodyDocumentType'; export * from './upstreamUnavailableResponse'; export * from './validationErrorEnvelope'; export * from './validationErrorEnvelopeError'; diff --git a/apps/frontend/src/api/generated/model/uploadMvpDocumentBody.ts b/apps/frontend/src/api/generated/model/uploadMvpDocumentBody.ts index 27e04e78..8cb08c40 100644 --- a/apps/frontend/src/api/generated/model/uploadMvpDocumentBody.ts +++ b/apps/frontend/src/api/generated/model/uploadMvpDocumentBody.ts @@ -5,7 +5,33 @@ * Versioned JSON contract consumed by the Angular SPA. * OpenAPI spec version: 1.0.0 */ +import type { UploadMvpDocumentBodyDocumentType } from './uploadMvpDocumentBodyDocumentType'; export type UploadMvpDocumentBody = { document: Blob; + /** + * Tipologia documento impostata manualmente in upload. Se presente, prevale sull'estrazione AI e non viene sovrascritta. + * @nullable + */ + documentType?: UploadMvpDocumentBodyDocumentType; + /** + * Azienda di riferimento impostata manualmente in upload. Se presente, prevale sull'estrazione AI e non viene sovrascritta. + * @maxLength 500 + * @nullable + */ + companyName?: string | null; + /** + * Mese di riferimento (1-12) impostato manualmente in upload. Combinato con year forma la data documento preservata dall'AI. + * @minimum 1 + * @maximum 12 + * @nullable + */ + month?: number | null; + /** + * Anno di riferimento impostato manualmente in upload. Combinato con month forma la data documento preservata dall'AI. + * @minimum 1900 + * @maximum 2100 + * @nullable + */ + year?: number | null; }; diff --git a/apps/frontend/src/api/generated/model/uploadMvpDocumentBodyDocumentType.ts b/apps/frontend/src/api/generated/model/uploadMvpDocumentBodyDocumentType.ts new file mode 100644 index 00000000..9ef549c7 --- /dev/null +++ b/apps/frontend/src/api/generated/model/uploadMvpDocumentBodyDocumentType.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v8.18.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ + +/** + * Tipologia documento impostata manualmente in upload. Se presente, prevale sull'estrazione AI e non viene sovrascritta. + * @nullable + */ +export type UploadMvpDocumentBodyDocumentType = typeof UploadMvpDocumentBodyDocumentType[keyof typeof UploadMvpDocumentBodyDocumentType] | null; + + +export const UploadMvpDocumentBodyDocumentType = { + cedolino: 'cedolino', + CU: 'CU', + comunicazione: 'comunicazione', + documento_da_firmare: 'documento da firmare', + lettera: 'lettera', + altro: 'altro', +} as const; diff --git a/apps/frontend/src/api/generated/mvp-api.ts b/apps/frontend/src/api/generated/mvp-api.ts index d7d24bfe..199aadf1 100644 --- a/apps/frontend/src/api/generated/mvp-api.ts +++ b/apps/frontend/src/api/generated/mvp-api.ts @@ -729,6 +729,18 @@ formData.append(`image`, updateMvpCommunicationCoverImageBody.image); uploadMvpDocument( uploadMvpDocumentBody: UploadMvpDocumentBody, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> {const formData = new FormData(); formData.append(`document`, uploadMvpDocumentBody.document); +if(uploadMvpDocumentBody.documentType !== undefined && uploadMvpDocumentBody.documentType !== null) { + formData.append(`documentType`, uploadMvpDocumentBody.documentType); + } +if(uploadMvpDocumentBody.companyName !== undefined && uploadMvpDocumentBody.companyName !== null) { + formData.append(`companyName`, uploadMvpDocumentBody.companyName); + } +if(uploadMvpDocumentBody.month !== undefined && uploadMvpDocumentBody.month !== null) { + formData.append(`month`, uploadMvpDocumentBody.month.toString()) + } +if(uploadMvpDocumentBody.year !== undefined && uploadMvpDocumentBody.year !== null) { + formData.append(`year`, uploadMvpDocumentBody.year.toString()) + } if (options?.observe === 'events') { return this.http.post( diff --git a/apps/frontend/src/app/features/copilot/components/document-upload-panel.css b/apps/frontend/src/app/features/copilot/components/document-upload-panel.css index dd7ac80e..895076a6 100644 --- a/apps/frontend/src/app/features/copilot/components/document-upload-panel.css +++ b/apps/frontend/src/app/features/copilot/components/document-upload-panel.css @@ -4,6 +4,88 @@ line-height: 1.45; } +.metadata { + display: grid; + gap: var(--mvp-space-4); + margin-bottom: var(--mvp-space-4); +} + +.types { + margin: 0; + padding: 0; + border: 0; + min-inline-size: 0; +} + +.types legend { + margin-bottom: var(--mvp-space-2); + color: var(--mvp-text); + font-size: var(--mvp-font-sm); + font-weight: 700; +} + +.typeButtons { + display: flex; + flex-wrap: wrap; + gap: var(--mvp-space-2); +} + +.typeButton { + min-height: 36px; + padding: 0 var(--mvp-space-3); + border: 1px solid var(--mvp-border-strong); + border-radius: var(--mvp-radius); + background: var(--mvp-surface-muted); + color: var(--mvp-text); + font: inherit; + font-size: var(--mvp-font-sm); + cursor: pointer; +} + +.typeButton:hover:not(:disabled) { + border-color: var(--mvp-text); +} + +.typeButton.active { + border-color: var(--mvp-text); + background: var(--mvp-text); + color: var(--mvp-surface); +} + +.typeButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--mvp-space-3); +} + +.field { + display: grid; + gap: var(--mvp-space-2); + color: var(--mvp-text); + font-weight: 700; +} + +.field span { + font-size: var(--mvp-font-sm); +} + +.field input, +.field select { + width: 100%; + min-height: 42px; + padding: 0 var(--mvp-space-3); + border: 1px solid var(--mvp-border-strong); + border-radius: var(--mvp-radius); + background: var(--mvp-surface-muted); + color: var(--mvp-text); + font: inherit; +} + .status { margin: 0; color: var(--mvp-muted); @@ -12,3 +94,9 @@ :host { display: contents; } + +@media (max-width: 900px) { + .fields { + grid-template-columns: 1fr; + } +} diff --git a/apps/frontend/src/app/features/copilot/components/document-upload-panel.spec.ts b/apps/frontend/src/app/features/copilot/components/document-upload-panel.spec.ts index c2948fc0..05bb0a9d 100644 --- a/apps/frontend/src/app/features/copilot/components/document-upload-panel.spec.ts +++ b/apps/frontend/src/app/features/copilot/components/document-upload-panel.spec.ts @@ -14,17 +14,53 @@ describe("DocumentUploadPanelComponent", () => { return fixture; } - it("propaga il file scelto dal dropzone", () => { - const fixture = render(); - const files: File[] = []; - fixture.componentInstance.upload.subscribe((file) => files.push(file)); + function selectFile(fixture: ReturnType, file: File): void { const input = (fixture.nativeElement as HTMLElement).querySelector('input[type="file"]')!; - const file = new File(["pdf"], "documento.pdf", { type: "application/pdf" }); Object.defineProperty(input, "files", { configurable: true, value: [file] }); - input.dispatchEvent(new Event("change")); + } + + it("propaga il file scelto dal dropzone senza metadati", () => { + const fixture = render(); + const requests: unknown[] = []; + fixture.componentInstance.upload.subscribe((request) => requests.push(request)); + const file = new File(["pdf"], "documento.pdf", { type: "application/pdf" }); + + selectFile(fixture, file); + + expect(requests).toEqual([{ file, metadata: {} }]); + }); + + it("include i metadati manuali selezionati nell'upload", () => { + const fixture = render(); + const requests: unknown[] = []; + fixture.componentInstance.upload.subscribe((request) => requests.push(request)); + + const buttons = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll(".typeButton") + ); + buttons.find((button) => button.textContent?.trim() === "cedolino")?.click(); + fixture.componentInstance["metadataForm"].patchValue({ + month: "3", + year: "2026", + companyName: " Acme Srl " + }); + fixture.detectChanges(); + + const file = new File(["pdf"], "cedolini.pdf", { type: "application/pdf" }); + selectFile(fixture, file); - expect(files).toEqual([file]); + expect(requests).toEqual([ + { + file, + metadata: { + documentType: "cedolino", + companyName: "Acme Srl", + month: 3, + year: 2026 + } + } + ]); }); it("mostra stato e avanzamento quando una fase e' attiva", () => { diff --git a/apps/frontend/src/app/features/copilot/components/document-upload-panel.ts b/apps/frontend/src/app/features/copilot/components/document-upload-panel.ts index b1ad6b49..c84ac6d2 100644 --- a/apps/frontend/src/app/features/copilot/components/document-upload-panel.ts +++ b/apps/frontend/src/app/features/copilot/components/document-upload-panel.ts @@ -1,23 +1,103 @@ -import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; +import { ChangeDetectionStrategy, Component, effect, input, output } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { FileDropzoneComponent } from "../../../shared/components/file-dropzone/file-dropzone"; import { SectionComponent } from "../../../layout/section/section"; -import type { DocumentUploadPhase } from "../data/document-workflow.service"; +import { DOCUMENT_TYPE_OPTIONS } from "../../../shared/util/document-field-validators"; +import type { DocumentUploadPhase, DocumentUploadMetadata } from "../data/document-workflow.service"; import { UploadProgressComponent } from "./upload-progress"; +const MONTHS = [ + { value: 1, label: "Gennaio" }, + { value: 2, label: "Febbraio" }, + { value: 3, label: "Marzo" }, + { value: 4, label: "Aprile" }, + { value: 5, label: "Maggio" }, + { value: 6, label: "Giugno" }, + { value: 7, label: "Luglio" }, + { value: 8, label: "Agosto" }, + { value: 9, label: "Settembre" }, + { value: 10, label: "Ottobre" }, + { value: 11, label: "Novembre" }, + { value: 12, label: "Dicembre" } +] as const; + +type DocumentTypeOption = (typeof DOCUMENT_TYPE_OPTIONS)[number]; + +/** Evento emesso quando l'utente seleziona un file da caricare, con eventuali metadati manuali. */ +export interface DocumentUploadRequest { + file: File; + metadata: DocumentUploadMetadata; +} + @Component({ selector: "mvp-document-upload-panel", changeDetection: ChangeDetectionStrategy.OnPush, - imports: [FileDropzoneComponent, SectionComponent, UploadProgressComponent], + imports: [FileDropzoneComponent, ReactiveFormsModule, SectionComponent, UploadProgressComponent], template: `

- Il sistema analizza automaticamente il documento dopo il caricamento e restituisce i campi rilevati - nella verifica. + Il sistema analizza automaticamente il documento dopo il caricamento. Tipologia, mese, anno e azienda + impostati qui restano autoritativi e non vengono sovrascritti dall'AI.

+ + + @if (phase() !== null) { @@ -31,5 +111,53 @@ export class DocumentUploadPanelComponent { readonly isUploading = input.required(); readonly status = input.required(); readonly phase = input.required(); - readonly upload = output(); + readonly upload = output(); + + protected readonly documentTypes = DOCUMENT_TYPE_OPTIONS; + protected readonly months = MONTHS; + + protected readonly metadataForm = new FormGroup({ + documentType: new FormControl("", { nonNullable: true }), + month: new FormControl("", { nonNullable: true }), + year: new FormControl("", { nonNullable: true }), + companyName: new FormControl("", { nonNullable: true }) + }); + + constructor() { + effect(() => { + if (this.isUploading()) { + this.metadataForm.disable({ emitEvent: false }); + } else { + this.metadataForm.enable({ emitEvent: false }); + } + }); + } + + protected selectDocumentType(option: DocumentTypeOption): void { + if (this.metadataForm.disabled) { + return; + } + + const current = this.metadataForm.controls.documentType.value; + this.metadataForm.controls.documentType.setValue(current === option ? "" : option); + } + + protected onFileSelected(file: File): void { + this.upload.emit({ file, metadata: this.toMetadata() }); + } + + private toMetadata(): DocumentUploadMetadata { + const value = this.metadataForm.getRawValue(); + const documentType = value.documentType.trim(); + const companyName = value.companyName.trim(); + const month = Number.parseInt(value.month, 10); + const year = Number.parseInt(value.year, 10); + + return { + documentType: documentType === "" ? undefined : (documentType as DocumentTypeOption), + companyName: companyName === "" ? undefined : companyName, + month: Number.isInteger(month) && month >= 1 && month <= 12 ? month : undefined, + year: Number.isInteger(year) && year >= 1900 && year <= 2100 ? year : undefined + }; + } } diff --git a/apps/frontend/src/app/features/copilot/copilot-page.spec.ts b/apps/frontend/src/app/features/copilot/copilot-page.spec.ts index 6158fa89..17f9bd14 100644 --- a/apps/frontend/src/app/features/copilot/copilot-page.spec.ts +++ b/apps/frontend/src/app/features/copilot/copilot-page.spec.ts @@ -95,15 +95,16 @@ describe("CopilotPage", () => { it("segue l'upload e seleziona il documento ricevuto", () => { const file = new File(["pdf"], "cedolino.pdf", { type: "application/pdf" }); + const metadata = { documentType: "cedolino" as const, companyName: "Acme Srl", month: 3, year: 2026 }; workflow["upload"].mockReturnValue(of( { status: "Caricato", phase: "processing" }, { status: "Analizzato", phase: "completed", receivedDocumentId: "sub-7" } )); const page = createPage(); - page["upload"](file); + page["upload"]({ file, metadata }); - expect(workflow["upload"]).toHaveBeenCalledWith(file); + expect(workflow["upload"]).toHaveBeenCalledWith(file, metadata); expect(page["uploadStatus"]()).toBe("Analizzato"); expect(page["uploadPhase"]()).toBe("completed"); expect(page["selectedDocumentId"]()).toBe("sub-7"); @@ -114,7 +115,7 @@ describe("CopilotPage", () => { workflow["upload"].mockReturnValue(throwError(() => new Error("upload fallito"))); const page = createPage(); - page["upload"](new File(["pdf"], "cedolino.pdf")); + page["upload"]({ file: new File(["pdf"], "cedolino.pdf"), metadata: {} }); expect(page["uploadPhase"]()).toBe("failed"); expect(page["uploadStatus"]()).toBe("upload fallito"); diff --git a/apps/frontend/src/app/features/copilot/copilot-page.ts b/apps/frontend/src/app/features/copilot/copilot-page.ts index 7c7963c5..ab58f204 100644 --- a/apps/frontend/src/app/features/copilot/copilot-page.ts +++ b/apps/frontend/src/app/features/copilot/copilot-page.ts @@ -21,7 +21,7 @@ import { } from "./data/document-workflow.service"; import { createDocumentFilterForm, toDocumentFilters } from "./data/document-filters"; import { DocumentListComponent } from "./components/document-list"; -import { DocumentUploadPanelComponent } from "./components/document-upload-panel"; +import { DocumentUploadPanelComponent, type DocumentUploadRequest } from "./components/document-upload-panel"; import { SubDocumentListComponent } from "./components/sub-document-list"; const MONTHS = [ @@ -275,13 +275,13 @@ export class CopilotPage { this.selectedDocumentId.set(documentId); } - protected upload(file: File): void { + protected upload(request: DocumentUploadRequest): void { this.isUploading.set(true); this.uploadPhase.set("uploading"); this.uploadStatus.set("Caricamento documento in corso."); this.workflow - .upload(file) + .upload(request.file, request.metadata) .pipe(finalize(() => this.isUploading.set(false))) .subscribe({ next: (progress) => { diff --git a/apps/frontend/src/app/features/copilot/data/document-workflow.service.spec.ts b/apps/frontend/src/app/features/copilot/data/document-workflow.service.spec.ts index b40e81a7..69d6eef4 100644 --- a/apps/frontend/src/app/features/copilot/data/document-workflow.service.spec.ts +++ b/apps/frontend/src/app/features/copilot/data/document-workflow.service.spec.ts @@ -105,6 +105,28 @@ describe("DocumentWorkflowService", () => { expect(FakeEventSource.last?.url).toBe("/api/v1/documents/1/stream"); }); + it("inoltra i metadati manuali insieme al file", () => { + const emissions: DocumentUploadProgress[] = []; + + service + .upload(file, { + documentType: "cedolino", + companyName: "Acme Srl", + month: 3, + year: 2026 + }) + .subscribe((progress) => emissions.push(progress)); + + expect(api.uploadMvpDocument).toHaveBeenCalledWith({ + document: file, + documentType: "cedolino", + companyName: "Acme Srl", + month: 3, + year: 2026 + }); + expect(emissions[0]?.phase).toBe("queued"); + }); + it.each([ ["pending", 0, "queued", "Documento in coda di elaborazione."], ["processing", 0, "processing", "Analisi OCR del documento in corso."], diff --git a/apps/frontend/src/app/features/copilot/data/document-workflow.service.ts b/apps/frontend/src/app/features/copilot/data/document-workflow.service.ts index a1d8a66f..e27bfba4 100644 --- a/apps/frontend/src/app/features/copilot/data/document-workflow.service.ts +++ b/apps/frontend/src/app/features/copilot/data/document-workflow.service.ts @@ -12,6 +12,7 @@ import type { } from "../../../../api/generated/model"; import { MvpStateStore } from "../../../core/state/mvp-state.store"; import { getSubDocumentNumericId } from "../../../shared/util/formatters"; +import type { DOCUMENT_TYPE_OPTIONS } from "../../../shared/util/document-field-validators"; /** Stato dell'anteprima PDF di un sotto-documento. */ export type DocumentPreviewStatus = "idle" | "loading" | "available" | "unavailable" | "unreachable"; @@ -63,6 +64,14 @@ export interface DocumentFilters { year?: number; } +/** Metadati manuali inviati insieme al file in upload. */ +export interface DocumentUploadMetadata { + documentType?: (typeof DOCUMENT_TYPE_OPTIONS)[number]; + companyName?: string; + month?: number; + year?: number; +} + /** * Pipeline documentale del Co-Pilot: upload con elaborazione asincrona via SSE, * revisione/validazione dei dati estratti, eliminazione e verifica anteprima. @@ -81,58 +90,66 @@ export class DocumentWorkflowService { * connessioni). Nessun fallback automatico: in caso di errore lo stato * documentale viene solo ricaricato per riflettere la situazione reale. */ - upload(file: File): Observable { + upload(file: File, metadata: DocumentUploadMetadata = {}): Observable { return new Observable((observer) => { let eventSource: EventSource | null = null; - const subscription = this.api.uploadMvpDocument({ document: file }).subscribe({ - next: (response) => { - observer.next({ status: response.message, phase: "queued" }); + const subscription = this.api + .uploadMvpDocument({ + document: file, + ...(metadata.documentType !== undefined ? { documentType: metadata.documentType } : {}), + ...(metadata.companyName !== undefined ? { companyName: metadata.companyName } : {}), + ...(metadata.month !== undefined ? { month: metadata.month } : {}), + ...(metadata.year !== undefined ? { year: metadata.year } : {}) + }) + .subscribe({ + next: (response) => { + observer.next({ status: response.message, phase: "queued" }); - eventSource = new EventSource(response.streamUrl); + eventSource = new EventSource(response.streamUrl); - eventSource.addEventListener("progress", (event) => { - const progress = JSON.parse((event as MessageEvent).data) as ProcessingProgressEvent; - observer.next({ - status: progressStatusLabel(progress), - phase: progressPhase(progress) + eventSource.addEventListener("progress", (event) => { + const progress = JSON.parse((event as MessageEvent).data) as ProcessingProgressEvent; + observer.next({ + status: progressStatusLabel(progress), + phase: progressPhase(progress) + }); }); - }); - eventSource.addEventListener("document", (event) => { - const document = JSON.parse((event as MessageEvent).data) as SubDocument; - this.store.upsertDocument(document); - observer.next({ - status: "Estrazione dati dai sotto-documenti in corso.", - phase: "extracting", - receivedDocumentId: document.id + eventSource.addEventListener("document", (event) => { + const document = JSON.parse((event as MessageEvent).data) as SubDocument; + this.store.upsertDocument(document); + observer.next({ + status: "Estrazione dati dai sotto-documenti in corso.", + phase: "extracting", + receivedDocumentId: document.id + }); }); - }); - eventSource.addEventListener("done", (event) => { - const payload = JSON.parse((event as MessageEvent).data) as { state?: MvpState }; + eventSource.addEventListener("done", (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { state?: MvpState }; - if (payload.state) { - this.store.setState(payload.state); - } + if (payload.state) { + this.store.setState(payload.state); + } - observer.next({ status: "Elaborazione completata.", phase: "completed" }); - eventSource?.close(); - observer.complete(); - }); + observer.next({ status: "Elaborazione completata.", phase: "completed" }); + eventSource?.close(); + observer.complete(); + }); - eventSource.addEventListener("error", () => { - observer.next({ - status: "Elaborazione non disponibile. Controlla lo stato del documento.", - phase: "failed" + eventSource.addEventListener("error", () => { + observer.next({ + status: "Elaborazione non disponibile. Controlla lo stato del documento.", + phase: "failed" + }); + eventSource?.close(); + this.store.reload(); + observer.complete(); }); - eventSource?.close(); - this.store.reload(); - observer.complete(); - }); - }, - error: (error: unknown) => observer.error(error) - }); + }, + error: (error: unknown) => observer.error(error) + }); return () => { subscription.unsubscribe(); diff --git a/database/migrations/2026_08_03_000000_add_manual_upload_metadata_to_original_documents.php b/database/migrations/2026_08_03_000000_add_manual_upload_metadata_to_original_documents.php new file mode 100644 index 00000000..729cdb81 --- /dev/null +++ b/database/migrations/2026_08_03_000000_add_manual_upload_metadata_to_original_documents.php @@ -0,0 +1,31 @@ +string('manual_document_type', 200)->nullable()->after('original_filename'); + $table->string('manual_company_name', 500)->nullable()->after('manual_document_type'); + $table->unsignedTinyInteger('manual_reference_month')->nullable()->after('manual_company_name'); + $table->unsignedSmallInteger('manual_reference_year')->nullable()->after('manual_reference_month'); + }); + } + + public function down(): void + { + Schema::table('original_documents', function (Blueprint $table) { + $table->dropColumn([ + 'manual_document_type', + 'manual_company_name', + 'manual_reference_month', + 'manual_reference_year', + ]); + }); + } +}; diff --git a/docs/mvp-scope.md b/docs/mvp-scope.md index 06981aee..e5aa352d 100644 --- a/docs/mvp-scope.md +++ b/docs/mvp-scope.md @@ -91,13 +91,14 @@ Incluso: nome/cognome/azienda (UC-35), stato di invio (UC-36), soglia di confidenza sopra o sotto un valore (UC-37), mese e anno del documento (UC-38); - stato `failed` esplicito quando split o estrazione non riescono; -- metriche operative su documenti elaborati, soglie di confidenza e stato di invio. +- metriche operative su documenti elaborati, soglie di confidenza e stato di invio; +- classificazione e metadati manuali in fase di upload: tipologia, mese, anno e azienda + impostati dal consulente restano autoritativi e non vengono sovrascritti dall'AI. In corso: - visualizzazione dell'email destinatario e della data/ora di caricamento nel dettaglio (UC-39.12, UC-39.15); -- classificazione e metadati manuali in fase di upload (UC-32). Fuori scope MVP: diff --git a/openapi/v1/alittlebyte-mvp-api.yaml b/openapi/v1/alittlebyte-mvp-api.yaml index 22117ed0..bd63401f 100644 --- a/openapi/v1/alittlebyte-mvp-api.yaml +++ b/openapi/v1/alittlebyte-mvp-api.yaml @@ -468,6 +468,32 @@ paths: document: type: string format: binary + documentType: + description: > + Tipologia documento impostata manualmente in upload. + Se presente, prevale sull'estrazione AI e non viene sovrascritta. + type: [string, "null"] + enum: [cedolino, CU, comunicazione, documento da firmare, lettera, altro] + companyName: + description: > + Azienda di riferimento impostata manualmente in upload. + Se presente, prevale sull'estrazione AI e non viene sovrascritta. + type: [string, "null"] + maxLength: 500 + month: + description: > + Mese di riferimento (1-12) impostato manualmente in upload. + Combinato con year forma la data documento preservata dall'AI. + type: [integer, "null"] + minimum: 1 + maximum: 12 + year: + description: > + Anno di riferimento impostato manualmente in upload. + Combinato con month forma la data documento preservata dall'AI. + type: [integer, "null"] + minimum: 1900 + maximum: 2100 responses: "202": description: Document accepted for asynchronous processing. diff --git a/tests/Feature/DocumentExtractionTest.php b/tests/Feature/DocumentExtractionTest.php index b03cda7a..235099ab 100644 --- a/tests/Feature/DocumentExtractionTest.php +++ b/tests/Feature/DocumentExtractionTest.php @@ -157,3 +157,38 @@ ->and($subDocument->fresh()->error_message)->toContain('quarantena') ->and(ExtractedData::query()->where('sub_document_id', $subDocument->id)->exists())->toBeFalse(); }); + +test('manual upload metadata overrides AI extraction without rewriting ai payload', function () { + $this->mock(BedrockService::class, function ($mock) { + $mock->shouldReceive('extractFields') + ->once() + ->andReturn([ + 'employee_first_name' => 'Mario', + 'employee_last_name' => 'Rossi', + 'company_name' => 'AI Company', + 'document_date' => '2024-06-15', + 'document_type' => 'lettera', + 'description' => 'Estratto AI', + 'confidence_score' => 90, + ]); + }); + + $subDocument = SubDocument::factory()->create(); + $subDocument->originalDocument->update([ + 'manual_document_type' => 'cedolino', + 'manual_company_name' => 'Acme Srl', + 'manual_reference_month' => 3, + 'manual_reference_year' => 2026, + ]); + + app(DocumentProcessingService::class)->extractAndSaveFields($subDocument->fresh(['originalDocument'])); + + $extracted = $subDocument->fresh()->extractedData; + + expect($extracted->document_type)->toBe('cedolino') + ->and($extracted->company_name)->toBe('Acme Srl') + ->and($extracted->document_date?->toDateString())->toBe('2026-03-01') + ->and($extracted->ai_payload['document_type'])->toBe('lettera') + ->and($extracted->ai_payload['company_name'])->toBe('AI Company') + ->and($extracted->ai_payload['document_date'])->toBe('2024-06-15'); +}); diff --git a/tests/Feature/MvpAppRoutesTest.php b/tests/Feature/MvpAppRoutesTest.php index 3055a4fd..8c1df9b1 100644 --- a/tests/Feature/MvpAppRoutesTest.php +++ b/tests/Feature/MvpAppRoutesTest.php @@ -308,6 +308,62 @@ function mvpRunWorkflowTask(OriginalDocument $document, string $taskType = 'bedr ->and($event->correlation_id)->toBe('corr-42'); }); +test('document upload stores manual metadata and preserves it over AI extraction', function () { + config([ + 'filesystems.default' => 's3', + ]); + + Queue::fake(); + Storage::fake('s3'); + mvpMockWorkflowStart($this); + + $this->mock(BedrockService::class, function ($mock) { + $mock->shouldReceive('splitDocument') + ->once() + ->andReturn([ + ['employee_name' => 'Mario Rossi', 'start_page' => 1, 'end_page' => 1], + ]); + + $mock->shouldReceive('extractFields') + ->once() + ->andReturn([ + 'employee_first_name' => 'Mario', + 'employee_last_name' => 'Rossi', + 'company_name' => 'Valore AI', + 'document_date' => '2024-01-15', + 'document_type' => 'lettera', + 'description' => 'Estratto AI', + 'confidence_score' => 86, + ]); + }); + + $this->post('/api/v1/documents/ocr', [ + 'document' => mvpPdfUpload(), + 'documentType' => 'cedolino', + 'companyName' => 'Acme Srl', + 'month' => 3, + 'year' => 2026, + ])->assertStatus(202); + + $document = OriginalDocument::query()->first(); + + expect($document->manual_document_type)->toBe('cedolino') + ->and($document->manual_company_name)->toBe('Acme Srl') + ->and($document->manual_reference_month)->toBe(3) + ->and($document->manual_reference_year)->toBe(2026); + + $document->update(['ocr_text' => "[Pagina 1]\nMario Rossi", 'ocr_confidence_avg' => 97.5]); + mvpRunWorkflowTask($document); + + $extracted = ExtractedData::query()->first(); + + expect($extracted->document_type)->toBe('cedolino') + ->and($extracted->company_name)->toBe('Acme Srl') + ->and($extracted->document_date?->toDateString())->toBe('2026-03-01') + ->and($extracted->ai_payload['document_type'])->toBe('lettera') + ->and($extracted->ai_payload['company_name'])->toBe('Valore AI'); +}); + test('document upload performs initial split and field extraction', function () { config([ 'filesystems.default' => 's3', From 5a390bd5c9ef1ae7703667d56c0cf5c12e60b05b Mon Sep 17 00:00:00 2001 From: eleonorabellet Date: Mon, 3 Aug 2026 17:18:09 +0200 Subject: [PATCH 03/27] aggiunta e rimozione generazione dai preferiti --- .../Api/V1/CommunicationController.php | 57 ++++++++++++ app/Models/Communication.php | 3 + app/Mvp/Support/MvpStateService.php | 1 + .../src/api/generated/model/communication.ts | 1 + .../frontend/src/api/generated/model/index.ts | 1 + .../updateCommunicationFavoriteResponse.ts | 15 ++++ apps/frontend/src/api/generated/mvp-api.ts | 72 +++++++++++++++ .../app/features/assistant/assistant-page.ts | 36 +++++++- .../components/communication-status-card.css | 15 ++++ .../assistant/data/assistant.service.ts | 13 +++ database/factories/CommunicationFactory.php | 6 ++ ...dd_is_favorite_to_communications_table.php | 24 +++++ docs/mvp-scope.md | 4 +- openapi/v1/alittlebyte-mvp-api.yaml | 53 ++++++++++- routes/api.php | 8 ++ tests/Feature/CommunicationFavoriteTest.php | 89 +++++++++++++++++++ tests/Feature/OpenApiContractTest.php | 52 +++++++++++ 17 files changed, 445 insertions(+), 5 deletions(-) create mode 100644 apps/frontend/src/api/generated/model/updateCommunicationFavoriteResponse.ts create mode 100644 database/migrations/2026_08_03_000000_add_is_favorite_to_communications_table.php create mode 100644 tests/Feature/CommunicationFavoriteTest.php diff --git a/app/Http/Controllers/Api/V1/CommunicationController.php b/app/Http/Controllers/Api/V1/CommunicationController.php index e3c347c5..2d61a495 100644 --- a/app/Http/Controllers/Api/V1/CommunicationController.php +++ b/app/Http/Controllers/Api/V1/CommunicationController.php @@ -85,6 +85,7 @@ public function store( 'generation_status' => CommunicationGenerationStatus::Pending, 'cover_status' => CoverImageStatus::Pending, 'status' => CommunicationStatus::Draft, + 'is_favorite' => false, ]); $audit->record( @@ -108,6 +109,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, diff --git a/app/Models/Communication.php b/app/Models/Communication.php index fa6eeeaa..33dd6f82 100644 --- a/app/Models/Communication.php +++ b/app/Models/Communication.php @@ -35,6 +35,7 @@ * @property CoverImageStatus $cover_status * @property string|null $cover_error * @property CommunicationStatus $status + * @property bool $is_favorite * @property int|null $rating * @property string|null $rating_comment * @property Carbon|null $rated_at @@ -68,6 +69,7 @@ class Communication extends Model 'cover_status', 'cover_error', 'status', + 'is_favorite', 'rating', 'rating_comment', 'rated_at', @@ -81,6 +83,7 @@ protected function casts(): array { return [ 'status' => CommunicationStatus::class, + 'is_favorite' => 'boolean', 'generation_status' => CommunicationGenerationStatus::class, 'cover_image_source' => CoverImageSource::class, 'cover_status' => CoverImageStatus::class, diff --git a/app/Mvp/Support/MvpStateService.php b/app/Mvp/Support/MvpStateService.php index d120ab79..19ada71b 100644 --- a/app/Mvp/Support/MvpStateService.php +++ b/app/Mvp/Support/MvpStateService.php @@ -121,6 +121,7 @@ public function communication(Communication $communication): array 'error' => $communication->error_message, 'status' => $communication->status->label(), 'statusValue' => $communication->status->value, + 'isFavorite' => (bool) $communication->is_favorite, 'createdAt' => $communication->created_at?->format('d/m/Y H:i'), 'rating' => $communication->rating, 'ratingComment' => $communication->rating_comment, diff --git a/apps/frontend/src/api/generated/model/communication.ts b/apps/frontend/src/api/generated/model/communication.ts index 0b89d5e2..4fd45139 100644 --- a/apps/frontend/src/api/generated/model/communication.ts +++ b/apps/frontend/src/api/generated/model/communication.ts @@ -31,6 +31,7 @@ export interface Communication { error?: string | null; status: string; statusValue?: string; + isFavorite: boolean; /** @nullable */ createdAt?: string | null; /** diff --git a/apps/frontend/src/api/generated/model/index.ts b/apps/frontend/src/api/generated/model/index.ts index d0e0e1de..ed09da89 100644 --- a/apps/frontend/src/api/generated/model/index.ts +++ b/apps/frontend/src/api/generated/model/index.ts @@ -36,6 +36,7 @@ export * from './subDocument'; export * from './subDocumentReviewStatus'; export * from './subDocumentSendStatus'; export * from './unauthorizedResponse'; +export * from './updateCommunicationFavoriteResponse'; export * from './updateCommunicationRequest'; export * from './updateCommunicationResponse'; export * from './updateExtractedDataRequest'; diff --git a/apps/frontend/src/api/generated/model/updateCommunicationFavoriteResponse.ts b/apps/frontend/src/api/generated/model/updateCommunicationFavoriteResponse.ts new file mode 100644 index 00000000..a7d2d09a --- /dev/null +++ b/apps/frontend/src/api/generated/model/updateCommunicationFavoriteResponse.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.18.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ +import type { Communication } from './communication'; +import type { MvpState } from './mvpState'; + +export interface UpdateCommunicationFavoriteResponse { + message: string; + communication: Communication; + state: MvpState; +} diff --git a/apps/frontend/src/api/generated/mvp-api.ts b/apps/frontend/src/api/generated/mvp-api.ts index d7d24bfe..c94995c1 100644 --- a/apps/frontend/src/api/generated/mvp-api.ts +++ b/apps/frontend/src/api/generated/mvp-api.ts @@ -37,6 +37,7 @@ import type { RateCommunicationRequest, RateCommunicationResponse, StartCommunicationGenerationResponse, + UpdateCommunicationFavoriteResponse, UpdateCommunicationRequest, UpdateCommunicationResponse, UpdateExtractedDataRequest, @@ -268,6 +269,77 @@ export class AlittlebyteMVPAPIService { ); } +/** + * @summary Add a communication to favorites + */ + favoriteMvpCommunication(communication: number, options?: HttpClientBodyOptions): Observable; + favoriteMvpCommunication(communication: number, options?: HttpClientEventOptions): Observable>; + favoriteMvpCommunication(communication: number, options?: HttpClientResponseOptions): Observable>; + favoriteMvpCommunication( + communication: number, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `/api/v1/communications/${communication}/favorite`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `/api/v1/communications/${communication}/favorite`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.post( + `/api/v1/communications/${communication}/favorite`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + +/** + * @summary Remove a communication from favorites + */ + unfavoriteMvpCommunication(communication: number, options?: HttpClientBodyOptions): Observable; + unfavoriteMvpCommunication(communication: number, options?: HttpClientEventOptions): Observable>; + unfavoriteMvpCommunication(communication: number, options?: HttpClientResponseOptions): Observable>; + unfavoriteMvpCommunication( + communication: number, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.delete( + `/api/v1/communications/${communication}/favorite`,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.delete( + `/api/v1/communications/${communication}/favorite`,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.delete( + `/api/v1/communications/${communication}/favorite`,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + /** * @summary Update the title and body of a draft communication */ diff --git a/apps/frontend/src/app/features/assistant/assistant-page.ts b/apps/frontend/src/app/features/assistant/assistant-page.ts index 44242465..5297e4b3 100644 --- a/apps/frontend/src/app/features/assistant/assistant-page.ts +++ b/apps/frontend/src/app/features/assistant/assistant-page.ts @@ -1,17 +1,17 @@ import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { LucideTrash2 } from "@lucide/angular"; +import { LucideStar, LucideTrash2 } from "@lucide/angular"; import { debounceTime, distinctUntilChanged, finalize } from "rxjs"; import { AssistantService, type CommunicationFilters } from "./data/assistant.service"; import type { Communication, UpdateCommunicationRequest } from "../../../api/generated/model"; import { MvpStateStore } from "../../core/state/mvp-state.store"; import { getApiErrorMessage } from "../../core/errors/api-error"; +import { ButtonComponent } from "../../shared/components/button/button"; import { EmptyStateComponent } from "../../shared/components/empty-state/empty-state"; import { ErrorStateComponent } from "../../shared/components/error-state/error-state"; import { SectionComponent } from "../../layout/section/section"; import { StatusBadgeComponent } from "../../shared/components/status-badge/status-badge"; -import { ButtonComponent } from "../../shared/components/button/button"; import { formatFallback } from "../../shared/util/formatters"; import { CommunicationGeneratorPanelComponent } from "./components/communication-generator-panel"; import { GeneratedCommunicationPreviewComponent } from "./components/generated-communication-preview"; @@ -33,6 +33,7 @@ import type { EmptyStateComponent, ErrorStateComponent, GeneratedCommunicationPreviewComponent, + LucideStar, LucideTrash2, ReactiveFormsModule, SectionComponent, @@ -118,6 +119,21 @@ import type {

{{ formatFallback(communication.createdAt) }}

+ @if (confirmingDeleteId() !== communication.id) { + } +
+ @if (copiedEmail()) { + Email copiata negli appunti. + } + +