docs(02): create phase plan

Phase 02: Prompt Control + Output Review
- 2 plan(s) in 2 wave(s)
- Wave 1: 02-01 (Prompt Editor backend+frontend)
- Wave 2: 02-02 (Per-item regen + summary counter)
- Ready for execution
This commit is contained in:
Michele
2026-03-08 20:17:29 +01:00
parent c22d9dde97
commit 5ba641e7d6
3 changed files with 651 additions and 3 deletions

View File

@@ -47,11 +47,11 @@ Plans:
1. L'utente apre la sezione Prompt Editor, vede la lista dei file .txt disponibili, clicca su un prompt e ne modifica il contenuto direttamente nel browser
2. Dopo aver salvato un prompt modificato, l'utente genera un nuovo post e il contenuto prodotto riflette le modifiche apportate al prompt
3. L'utente puo' rigenerare un singolo post dell'anteprima senza rigenerare l'intero batch
**Plans**: TBD
**Plans**: 2 plans
Plans:
- [ ] 02-01: PromptService CRUD completo — endpoint FastAPI per lista/leggi/scrivi file prompt, validazione variabili richieste, UI Prompt Editor (lista, textarea, salva)
- [ ] 02-02: Per-item regeneration — endpoint rigenera singolo post per ID slot, aggiornamento anteprima senza rigenerare il batch, integrazione con Output Review UI
- [ ] 02-01-PLAN.md — Prompt Editor: backend prompts router CRUD (list/read/write/reset) + frontend pagina PromptEditor con lista, textarea, variabili, badge modificato/default (Wave 1)
- [ ] 02-02-PLAN.md — Per-item regeneration: bottone Rigenera con popover inline (topic/note override), badge rigenerato, summary counter rigenerati/modificati in OutputReview (Wave 2)
---

View File

@@ -0,0 +1,315 @@
---
phase: 02-prompt-control-output-review
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/routers/prompts.py
- backend/main.py
- frontend/src/types.ts
- frontend/src/api/hooks.ts
- frontend/src/pages/PromptEditor.tsx
- frontend/src/App.tsx
- frontend/src/components/Sidebar.tsx
autonomous: true
must_haves:
truths:
- "GET /api/prompts ritorna lista dei prompt .txt disponibili con flag modificato/default"
- "GET /api/prompts/{name} ritorna il contenuto grezzo del prompt e le variabili richieste"
- "PUT /api/prompts/{name} salva il contenuto modificato del prompt con validazione variabili obbligatorie"
- "POST /api/prompts/{name}/reset ripristina il prompt al contenuto default originale"
- "L'utente vede la pagina Prompt Editor nella sidebar e puo' navigarci"
- "L'utente vede la lista dei prompt, seleziona uno, e ne modifica il contenuto in una textarea"
- "L'utente vede le variabili richieste dal prompt selezionato con il nome di ciascuna"
- "L'utente puo' salvare un prompt modificato e il badge Modificato appare nella lista"
- "L'utente puo' resettare un prompt al default originale con il pulsante Reset"
artifacts:
- path: "backend/routers/prompts.py"
provides: "API CRUD per prompt: list, read, write, reset, variables"
contains: "router = APIRouter"
- path: "frontend/src/pages/PromptEditor.tsx"
provides: "Pagina Prompt Editor con lista, textarea, variabili, salva, reset"
contains: "export function PromptEditor"
key_links:
- from: "frontend/src/pages/PromptEditor.tsx"
to: "/api/prompts"
via: "TanStack Query hooks usePromptList, usePrompt, useSavePrompt, useResetPrompt"
pattern: "usePromptList|usePrompt|useSavePrompt|useResetPrompt"
- from: "frontend/src/App.tsx"
to: "frontend/src/pages/PromptEditor.tsx"
via: "React Router route /prompt-editor"
pattern: "Route.*prompt-editor"
- from: "frontend/src/components/Sidebar.tsx"
to: "/prompt-editor"
via: "NavLink"
pattern: "prompt-editor"
- from: "backend/main.py"
to: "backend/routers/prompts.py"
via: "app.include_router"
pattern: "include_router.*prompts"
---
<objective>
PromptService CRUD completo — endpoint FastAPI per lista/leggi/scrivi file prompt con validazione variabili e reset al default, UI Prompt Editor con lista prompt, textarea per modifica, badge modificato/default, variabili richieste, e pulsante reset.
Purpose: Dare all'utente il controllo completo sui prompt LLM direttamente dalla Web UI, senza dover accedere ai file sul server o modificare il codice. Copre i requirement PRM-05 e UI-05.
Output: Backend router prompts.py con 4 endpoint + pagina React PromptEditor.tsx con navigazione integrata
</objective>
<execution_context>
@C:\Users\miche\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\miche\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@backend/services/prompt_service.py
@backend/main.py
@backend/config.py
@backend/routers/settings.py
@frontend/src/api/client.ts
@frontend/src/api/hooks.ts
@frontend/src/types.ts
@frontend/src/App.tsx
@frontend/src/components/Sidebar.tsx
</context>
<tasks>
<task type="auto">
<name>Task 1: Backend prompts router — 4 endpoint CRUD per prompt con reset al default</name>
<files>backend/routers/prompts.py, backend/main.py</files>
<action>
Crea `backend/routers/prompts.py` con `APIRouter(prefix="/api/prompts", tags=["prompts"])` seguendo lo stesso pattern di `backend/routers/settings.py`.
**Crea istanza PromptService** da PROMPTS_PATH (importa da backend.config).
**Endpoint 1: GET /api/prompts** — Lista prompt disponibili.
- Chiama `prompt_service.list_prompts()` per ottenere i nomi.
- Per ciascuno, determina se e' stato modificato rispetto al default: confronta il contenuto in PROMPTS_PATH con il file corrispondente in `backend/data/prompts/` (la directory sorgente dei default). Se il file default non esiste O il contenuto differisce -> `modified: true`.
- Response schema `PromptListResponse`:
```python
class PromptInfo(BaseModel):
name: str # Nome senza estensione (es. "pas_valore")
modified: bool # True se diverso dal default o se default non esiste
class PromptListResponse(BaseModel):
prompts: list[PromptInfo]
```
**Endpoint 2: GET /api/prompts/{name}** — Leggi prompt con variabili richieste.
- Valida che il prompt esista con `prompt_service.prompt_exists(name)`, altrimenti HTTPException 404.
- Chiama `prompt_service.load_prompt(name)` per il contenuto.
- Chiama `prompt_service.get_required_variables(name)` per le variabili.
- Determina `modified` confrontando con default (come sopra).
- Response schema `PromptDetailResponse`:
```python
class PromptDetailResponse(BaseModel):
name: str
content: str
variables: list[str] # ["brand_name", "livello_schwartz", "obiettivo_campagna", ...]
modified: bool
```
**Endpoint 3: PUT /api/prompts/{name}** — Salva prompt modificato.
- Request body:
```python
class SavePromptRequest(BaseModel):
content: str = Field(..., min_length=10, description="Contenuto del prompt")
```
- Chiama `prompt_service.save_prompt(name, content)`.
- Dopo il salvataggio, ritorna `PromptDetailResponse` con i dati aggiornati (incluse le variabili ricalcolate dal nuovo contenuto).
- Se `prompt_service.save_prompt()` lancia ValueError (nome non valido), ritorna HTTPException 400.
**Endpoint 4: POST /api/prompts/{name}/reset** — Reset al default.
- Trova il file default in `Path(__file__).parent.parent / "data" / "prompts" / f"{name}.txt"` (stessa directory usata in main.py lifespan per la copia iniziale).
- Se il default non esiste, HTTPException 404 con messaggio "Nessun default disponibile per questo prompt".
- Copia il contenuto default su PROMPTS_PATH sovrascrivendo il corrente.
- Ritorna `PromptDetailResponse` con il contenuto ripristinato.
**Costante helper** per la directory default:
```python
_DEFAULT_PROMPTS_DIR = Path(__file__).parent.parent / "data" / "prompts"
```
**Funzione helper** `_is_modified(name: str) -> bool`:
```python
def _is_modified(name: str) -> bool:
default_path = _DEFAULT_PROMPTS_DIR / f"{name}.txt"
if not default_path.exists():
return True # No default = considered custom/modified
current = prompt_service.load_prompt(name)
default = default_path.read_text(encoding="utf-8")
return current != default
```
**Registra il router in main.py**: Aggiungi `from backend.routers import prompts` e `app.include_router(prompts.router)` PRIMA del SPA catch-all mount, dopo gli altri router (stessa posizione degli altri `include_router`).
</action>
<verify>
Verifica che il file `backend/routers/prompts.py` esista e contenga i 4 endpoint.
Verifica che `backend/main.py` includa `app.include_router(prompts.router)`.
Apri un terminale: `cd lab/postgenerator && python -c "from backend.routers.prompts import router; print('OK:', [r.path for r in router.routes])"` per verificare che il modulo si importi senza errori.
</verify>
<done>
4 endpoint registrati: GET /api/prompts, GET /api/prompts/{name}, PUT /api/prompts/{name}, POST /api/prompts/{name}/reset. Il router si importa senza errori e main.py lo include.
</done>
</task>
<task type="auto">
<name>Task 2: Frontend Prompt Editor — pagina, hooks, types, route, sidebar</name>
<files>frontend/src/types.ts, frontend/src/api/hooks.ts, frontend/src/pages/PromptEditor.tsx, frontend/src/App.tsx, frontend/src/components/Sidebar.tsx</files>
<action>
**1. Types** — Aggiungi in fondo a `frontend/src/types.ts`:
```typescript
// ---------------------------------------------------------------------------
// Prompt Editor
// ---------------------------------------------------------------------------
export interface PromptInfo {
name: string
modified: boolean
}
export interface PromptListResponse {
prompts: PromptInfo[]
}
export interface PromptDetail {
name: string
content: string
variables: string[]
modified: boolean
}
```
**2. Hooks** — Aggiungi in fondo a `frontend/src/api/hooks.ts`:
```typescript
// ---------------------------------------------------------------------------
// Prompt Editor
// ---------------------------------------------------------------------------
/** Lista tutti i prompt disponibili con flag modificato/default. */
export function usePromptList() {
return useQuery<PromptListResponse>({
queryKey: ['prompts'],
queryFn: () => apiGet<PromptListResponse>('/prompts'),
staleTime: 30_000,
})
}
/** Carica contenuto + variabili di un singolo prompt. */
export function usePrompt(name: string | null) {
return useQuery<PromptDetail>({
queryKey: ['prompts', name],
queryFn: () => apiGet<PromptDetail>(`/prompts/${name}`),
enabled: !!name,
staleTime: 0, // Sempre fresco dopo edit
})
}
/** Salva il contenuto modificato di un prompt. */
export function useSavePrompt() {
const queryClient = useQueryClient()
return useMutation<PromptDetail, Error, { name: string; content: string }>({
mutationFn: ({ name, content }) =>
apiPut<PromptDetail>(`/prompts/${name}`, { content }),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['prompts'] })
queryClient.setQueryData(['prompts', data.name], data)
},
})
}
/** Reset un prompt al default originale. */
export function useResetPrompt() {
const queryClient = useQueryClient()
return useMutation<PromptDetail, Error, string>({
mutationFn: (name) => apiPost<PromptDetail>(`/prompts/${name}/reset`),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['prompts'] })
queryClient.setQueryData(['prompts', data.name], data)
},
})
}
```
Aggiungi gli import dei nuovi types (PromptListResponse, PromptDetail) nell'import block di hooks.ts.
**3. Pagina PromptEditor.tsx** — Crea `frontend/src/pages/PromptEditor.tsx`.
Layout: due colonne affiancate su desktop (lg:), una colonna su mobile.
- **Colonna sinistra (1/3 su lg)**: Lista dei prompt con badge "Modificato" (amber pill) o "Default" (stone pill). Click su un prompt lo seleziona.
- **Colonna destra (2/3 su lg)**: Editor per il prompt selezionato.
- Header: nome prompt + badge modificato/default
- Textarea monospace full-width, min-height 400px, background stone-900, border stone-700. Il testo e' il contenuto grezzo del prompt.
- Sotto la textarea: sezione "Variabili richieste" con chip per ogni variabile (es. `{{topic}}`, `{{obiettivo_campagna}}`). Ogni chip in stone-700/amber-200 testo. Le variabili si aggiornano live dalla textarea (regex client-side) per dare feedback immediato PRIMA del salvataggio.
- Due bottoni:
- "Salva" (amber-500, primario) — chiama useSavePrompt, mostra toast-style feedback (testo sotto il bottone "Salvato con successo" / errore)
- "Reset al Default" (stone-600, secondario) — chiama useResetPrompt. Mostra conferma inline ("Sei sicuro? Ripristinera' il prompt originale.") prima di eseguire. Bottone disabilitato se il prompt non e' modificato. Dopo reset, aggiorna la textarea con il contenuto default.
Gestione stato:
- `selectedPrompt: string | null` — nome del prompt selezionato
- `editContent: string` — contenuto nella textarea (inizializzato da usePrompt, aggiornato dall'utente)
- `dirty: boolean` — true se editContent != contenuto originale dal server (abilita bottone Salva)
- `showResetConfirm: boolean` — per la conferma inline del reset
- `clientVariables: string[]` — variabili estratte client-side da editContent con regex `/\{\{(\w+)\}\}/g`
Quando l'utente cambia prompt nella lista: se dirty, nessun prompt guard (non bloccare la navigazione, perdi le modifiche — workflow leggero). Resetta editContent con i dati del nuovo prompt.
Palette: stone/amber consistente con il resto dell'app. Textarea con font mono (font-mono).
**4. Route** — In `frontend/src/App.tsx`, aggiungi:
- Import: `import { PromptEditor } from './pages/PromptEditor'`
- Route: `<Route path="/prompt-editor" element={<PromptEditor />} />`
**5. Sidebar** — In `frontend/src/components/Sidebar.tsx`:
- Aggiungi icona import: `Pencil` da lucide-react (gia' disponibile nel pacchetto)
- Aggiungi nav item DOPO "Genera Singolo Post" e PRIMA di "Impostazioni":
```typescript
{
to: '/prompt-editor',
label: 'Prompt Editor',
icon: <Pencil size={18} />,
},
```
</action>
<verify>
Verifica che `frontend/src/pages/PromptEditor.tsx` esista e contenga `export function PromptEditor`.
Verifica che `frontend/src/App.tsx` contenga la route `/prompt-editor`.
Verifica che `frontend/src/components/Sidebar.tsx` contenga il link al Prompt Editor.
Verifica che `frontend/src/api/hooks.ts` contenga usePromptList, usePrompt, useSavePrompt, useResetPrompt.
Verifica che `frontend/src/types.ts` contenga PromptInfo, PromptListResponse, PromptDetail.
Esegui `cd lab/postgenerator/frontend && npx tsc --noEmit` per verificare che il TypeScript compili senza errori.
</verify>
<done>
La pagina Prompt Editor e' navigabile dalla sidebar, mostra la lista dei prompt con badge modificato/default, permette di editare il contenuto in una textarea monospace, mostra le variabili richieste come chip, salva le modifiche via API, e resetta al default con conferma. TypeScript compila senza errori.
</done>
</task>
</tasks>
<verification>
1. Il backend risponde a GET /api/prompts con la lista dei prompt e flag modified
2. Il backend risponde a GET /api/prompts/pas_valore con contenuto, variabili, e flag modified
3. PUT /api/prompts/pas_valore salva il nuovo contenuto e ritorna le variabili aggiornate
4. POST /api/prompts/pas_valore/reset ripristina il contenuto default
5. La pagina Prompt Editor e' raggiungibile dalla sidebar
6. L'utente puo' selezionare un prompt, modificarlo, salvarlo, e resettarlo al default
7. Le variabili richieste si aggiornano live durante la modifica
8. TypeScript compila senza errori (npx tsc --noEmit)
</verification>
<success_criteria>
- L'utente apre /prompt-editor, vede la lista dei prompt .txt, ne seleziona uno e ne modifica il contenuto
- Dopo aver salvato, un badge "Modificato" appare nella lista
- Il pulsante "Reset al Default" ripristina il contenuto originale
- Le variabili richieste sono visibili accanto all'editor
</success_criteria>
<output>
After completion, create `.planning/phases/02-prompt-control-output-review/02-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,333 @@
---
phase: 02-prompt-control-output-review
plan: 02
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- frontend/src/components/PostCard.tsx
- frontend/src/pages/OutputReview.tsx
autonomous: true
must_haves:
truths:
- "Ogni PostCard con status=success mostra un bottone Rigenera visibile nell'header della card"
- "Cliccando Rigenera si apre un popover/inline form leggero con campo topic opzionale e note opzionali"
- "Dopo la rigenerazione, il post aggiornato sostituisce quello originale nella griglia"
- "I post rigenerati hanno un badge visivo (icona freccia circolare) che li distingue dai post originali"
- "Un summary counter in cima alla Output Review mostra: N post - X rigenerati - Y modificati manualmente"
- "Il summary counter si aggiorna in tempo reale dopo ogni rigenerazione o modifica"
- "Il CSV scaricato contiene sempre le ultime versioni dei post (originali, rigenerati, e modificati inline)"
artifacts:
- path: "frontend/src/components/PostCard.tsx"
provides: "PostCard con bottone Rigenera, popover inline, badge rigenerato"
contains: "handleRegen"
- path: "frontend/src/pages/OutputReview.tsx"
provides: "OutputReview con summary counter rigenerati/modificati"
contains: "regeneratedCount"
key_links:
- from: "frontend/src/components/PostCard.tsx"
to: "/api/generate/single"
via: "useGenerateSingle mutation con topic override"
pattern: "useGenerateSingle|generateSingle"
- from: "frontend/src/pages/OutputReview.tsx"
to: "frontend/src/components/PostCard.tsx"
via: "onRegenerated callback che aggiorna localResults e traccia conteggi"
pattern: "handleRegenerated|regeneratedCount"
---
<objective>
Per-item regeneration con UX migliorata — bottone Rigenera diretto su ogni PostCard con form inline per topic/note override, badge visivo per post rigenerati, e summary counter in cima alla Output Review.
Purpose: L'utente puo' rigenerare singoli post insoddisfacenti direttamente dalla Output Review, con la possibilita' di specificare un topic diverso o note aggiuntive. Il summary counter fornisce una overview rapida dello stato del batch. Copre il terzo success criterion della Phase 2.
Output: PostCard.tsx potenziato con regen popover + OutputReview.tsx con tracking conteggi
</objective>
<execution_context>
@C:\Users\miche\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\miche\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-prompt-control-output-review/02-01-SUMMARY.md
@frontend/src/components/PostCard.tsx
@frontend/src/pages/OutputReview.tsx
@frontend/src/api/hooks.ts
@frontend/src/types.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: PostCard — bottone Rigenera con popover inline, topic override, badge rigenerato</name>
<files>frontend/src/components/PostCard.tsx</files>
<action>
Modifica `frontend/src/components/PostCard.tsx` per aggiungere il bottone Rigenera e il popover inline sui post con status=success.
**Nuove props** — Aggiungi alla interface PostCardProps:
```typescript
/** True se questo post e' stato rigenerato (non originale) */
isRegenerated?: boolean
```
**Stato interno** — Aggiungi:
```typescript
const [showRegenForm, setShowRegenForm] = useState(false)
const [regenTopic, setRegenTopic] = useState('')
const [regenNotes, setRegenNotes] = useState('')
```
**Funzione handleRegen** — Sostituisce/affianca handleRetry per i post success:
```typescript
async function handleRegen() {
if (!slot) return
// Se l'utente ha specificato un topic, usalo come override
const overriddenSlot = regenTopic.trim()
? { ...slot, topic: regenTopic.trim() }
: slot
const req: GenerateRequest = {
slot: overriddenSlot,
obiettivo_campagna: obiettivoCampagna,
brand_name: brandName,
tono: regenNotes.trim() || tono, // Se note fornite, usale come tono override
}
try {
const newResult = await generateSingle.mutateAsync(req)
onRegenerated({ ...newResult, slot })
setShowRegenForm(false)
setRegenTopic('')
setRegenNotes('')
} catch {
// Errore gestito da generateSingle.error
}
}
```
NOTA: Il campo `tono` nella GenerateRequest viene usato per passare note aggiuntive dell'utente. Questo funziona perche' il tono viene iniettato nel prompt come contesto aggiuntivo — e' il modo piu' pragmatico per influenzare la rigenerazione senza aggiungere un nuovo campo al backend.
**Sezione SUCCESS della card** — Modifica la sezione che renderizza i post con status=success:
1. **Badge rigenerato** — Se `isRegenerated` e' true, mostra un'icona `RefreshCw` (gia' importata) accanto al numero slot:
```tsx
{isRegenerated && (
<span className="text-amber-400" title="Post rigenerato">
<RefreshCw size={12} />
</span>
)}
```
Posiziona il badge nella riga dei badge, dopo `#N`, prima dei BadgePN/BadgeSchwartz.
2. **Bottone Rigenera** nell'header della card success — Aggiungi un piccolo bottone accanto alla chevron expand:
```tsx
<button
onClick={(e) => {
e.stopPropagation() // Non togglare expand
setShowRegenForm(v => !v)
}}
className="text-stone-500 hover:text-amber-400 transition-colors flex-shrink-0"
title="Rigenera questo post"
>
<RefreshCw size={14} />
</button>
```
Posiziona PRIMA della chevron expand/collapse nel div flex justify-between.
3. **Popover/form inline** — Sotto l'header della card, se `showRegenForm` e' true, mostra un form leggero:
```tsx
{showRegenForm && (
<div className="px-4 pb-3 border-t border-stone-700/50 pt-3 space-y-3">
<div>
<label className="text-xs text-stone-400 block mb-1">
Topic alternativo <span className="text-stone-600">(opzionale)</span>
</label>
<input
type="text"
value={regenTopic}
onChange={(e) => setRegenTopic(e.target.value)}
placeholder="Es: 3 errori comuni nel marketing digitale"
className="w-full px-3 py-2 rounded-lg bg-stone-900 border border-stone-700 text-sm text-stone-200 placeholder:text-stone-600 focus:border-amber-500/50 focus:outline-none"
/>
</div>
<div>
<label className="text-xs text-stone-400 block mb-1">
Note aggiuntive <span className="text-stone-600">(opzionale)</span>
</label>
<input
type="text"
value={regenNotes}
onChange={(e) => setRegenNotes(e.target.value)}
placeholder="Es: tono piu' provocatorio, focus su ROI"
className="w-full px-3 py-2 rounded-lg bg-stone-900 border border-stone-700 text-sm text-stone-200 placeholder:text-stone-600 focus:border-amber-500/50 focus:outline-none"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRegen}
disabled={generateSingle.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-500 text-stone-950 text-xs font-semibold hover:bg-amber-400 disabled:opacity-50 transition-colors"
>
{generateSingle.isPending ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} />
)}
{generateSingle.isPending ? 'Rigenerazione...' : 'Rigenera'}
</button>
<button
onClick={() => {
setShowRegenForm(false)
setRegenTopic('')
setRegenNotes('')
}}
className="px-3 py-1.5 rounded-lg text-xs text-stone-400 hover:text-stone-200 transition-colors"
>
Annulla
</button>
</div>
{generateSingle.error && (
<p className="text-xs text-red-400">{generateSingle.error.message}</p>
)}
</div>
)}
```
Il popover form appare SOTTO l'header ma SOPRA la sezione espansa (SlideViewer). Posizionalo tra il `</button>` dell'header e il blocco `{expanded && ...}`.
**handleRetry per post FAILED** — Rimane invariato (gia' funzionante). handleRetry e' per i post falliti, handleRegen e' per i post success.
</action>
<verify>
Verifica che `PostCard.tsx` contenga: handleRegen, showRegenForm, isRegenerated, regenTopic, regenNotes.
Verifica che la prop isRegenerated sia nella interface PostCardProps.
Esegui `cd lab/postgenerator/frontend && npx tsc --noEmit` per verificare che TypeScript compili.
</verify>
<done>
PostCard mostra un bottone Rigenera su ogni post success, con un form inline per topic/note override. I post rigenerati hanno un badge icona visivo. Il form e' leggero (inline, non modale). La rigenerazione funziona tramite l'endpoint POST /api/generate/single gia' esistente.
</done>
</task>
<task type="auto">
<name>Task 2: OutputReview — summary counter con tracking rigenerati/modificati</name>
<files>frontend/src/pages/OutputReview.tsx</files>
<action>
Modifica `frontend/src/pages/OutputReview.tsx` per aggiungere il summary counter e il tracking dei post rigenerati.
**Stato tracking** — Aggiungi un Set per tracciare gli indici dei post rigenerati:
```typescript
const [regeneratedSlots, setRegeneratedSlots] = useState<Set<number>>(new Set())
```
**Aggiorna handleRegenerated** per tracciare i post rigenerati:
```typescript
function handleRegenerated(updated: PostResult) {
setLocalResults((prev) =>
prev.map((r) => (r.slot_index === updated.slot_index ? updated : r))
)
setRegeneratedSlots((prev) => new Set(prev).add(updated.slot_index))
}
```
**Calcolo conteggi** per il summary counter:
```typescript
const successCount = localResults.filter((r) => r.status === 'success').length
const failedCount = localResults.filter((r) => r.status === 'failed').length
const regeneratedCount = regeneratedSlots.size
// "Modificato manualmente" = post con status success che sono cambiati rispetto ai dati originali
// Per semplicita', contiamo solo i post che hanno ricevuto edit inline (diversi dal jobData originale)
const editedCount = localResults.filter((r, i) => {
if (r.status !== 'success' || !r.post) return false
const original = jobData?.results?.[i]
if (!original?.post) return false
// Confronto shallow: se qualsiasi campo e' diverso, e' stato editato
return JSON.stringify(r.post) !== JSON.stringify(original.post)
}).length
// Sottrai i rigenerati dagli editati per evitare doppio conteggio
const manuallyEditedCount = Math.max(0, editedCount - regeneratedCount)
```
NOTA: I conteggi `editedCount` e `manuallyEditedCount` usano un confronto JSON.stringify pragmatico. Non e' il piu' performante, ma con 13 post e' trascurabile. Funziona perche' `localResults` tiene le versioni correnti e `jobData.results` tiene gli originali dal server.
**Summary counter UI** — Sostituisci la sezione header stats esistente (le righe con `{successCount} generati` e `{failedCount} falliti`) con:
```tsx
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2">
<span className="text-xs text-stone-300 font-medium">{localResults.length} post</span>
<span className="text-xs text-emerald-400">{successCount} generati</span>
{failedCount > 0 && (
<span className="text-xs text-red-400">{failedCount} falliti</span>
)}
{regeneratedCount > 0 && (
<span className="text-xs text-amber-400 flex items-center gap-1">
<RefreshCw size={10} />
{regeneratedCount} rigenerati
</span>
)}
{manuallyEditedCount > 0 && (
<span className="text-xs text-blue-400">
{manuallyEditedCount} modificati
</span>
)}
<span className="text-xs text-stone-600">job: {jobId}</span>
</div>
```
Aggiungi `RefreshCw` agli import da lucide-react.
**Passare isRegenerated a PostCard** — Nella griglia dei post, passa la prop:
```tsx
localResults.map((result) => (
<PostCard
key={result.slot_index}
result={result}
obiettivoCampagna={jobData.campagna}
brandName={null}
tono={null}
onRegenerated={handleRegenerated}
onEdit={handleEdit}
isRegenerated={regeneratedSlots.has(result.slot_index)}
/>
))
```
**Aggiorna il messaggio informativo** sotto l'header — Modifica il box info per includere regen:
```tsx
<div className="px-4 py-2.5 rounded-lg bg-stone-800/50 border border-stone-700 text-xs text-stone-500">
Clicca su una card per espandere le slide. I campi di testo sono editabili inline.
Usa il pulsante <RefreshCw size={10} className="inline" /> per rigenerare singoli post con topic diversi.
Le modifiche saranno incluse nel CSV scaricato.
</div>
```
</action>
<verify>
Verifica che `OutputReview.tsx` contenga: regeneratedSlots, regeneratedCount, manuallyEditedCount, isRegenerated prop passata a PostCard.
Verifica che il summary counter sia presente nell'header.
Esegui `cd lab/postgenerator/frontend && npx tsc --noEmit` per verificare che TypeScript compili.
</verify>
<done>
OutputReview mostra un summary counter con "N post - X rigenerati - Y modificati" che si aggiorna in tempo reale. I post rigenerati sono tracciati e la prop isRegenerated viene passata a PostCard per il badge visivo. Il CSV scaricato contiene sempre le ultime versioni (handleRegenerated aggiorna localResults che viene inviato al backend via POST).
</done>
</task>
</tasks>
<verification>
1. PostCard success mostra un bottone Rigenera (icona RefreshCw) nell'header
2. Click sul bottone Rigenera apre un form inline con campi topic e note (entrambi opzionali)
3. La rigenerazione chiama POST /api/generate/single con il topic/tono override e aggiorna la card
4. Post rigenerati mostrano un badge icona (RefreshCw amber) accanto al numero slot
5. Il summary counter in cima alla OutputReview mostra conteggi aggiornati per generati/falliti/rigenerati/modificati
6. Dopo rigenerazione, il summary counter si aggiorna immediatamente
7. Il CSV scaricato contiene la versione piu' recente di ogni post (originale, rigenerato, o editato)
8. TypeScript compila senza errori (npx tsc --noEmit)
</verification>
<success_criteria>
- L'utente puo' rigenerare un singolo post dalla Output Review specificando un topic diverso o note aggiuntive
- I post rigenerati sono visivamente distinguibili con un badge icona
- Il summary counter mostra una overview dello stato del batch in tempo reale
</success_criteria>
<output>
After completion, create `.planning/phases/02-prompt-control-output-review/02-02-SUMMARY.md`
</output>