Skip to content

Commit 7f3d4ea

Browse files
committed
fix(ai): self-check default-off + parsing tollerante (3B reviewer troppo severo)
1 parent 9dd6849 commit 7f3d4ea

3 files changed

Lines changed: 40 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ Modelli consigliati (da modificare in `Ai__Model` nel `.env`, poi `docker compos
332332
- **Tre livelli di guardrail**:
333333
1. *Input* — pattern regex + FluentValidation rifiutano tentativi di prompt injection ("ignora le istruzioni…", "act as DAN", ecc.), argomenti fuori scope (politica, trading, codice) e individuano segnali di autolesionismo per rispondere con i contatti di supporto.
334334
2. *Prompt hardening* — system prompt che fissa l'ambito caregiving + sandwich di "regola finale" ripetuta dopo l'input utente; ogni risposta fuori scope deve essere la sentinella `fuori_scopo`.
335-
3. *Output* — sentinella + cap di lunghezza + redazione PII; con `Ai__SelfCheckEnabled=true` (default) il modello viene interrogato una seconda volta per dichiarare "questa risposta è coerente?".
335+
3. *Output* — sentinella + cap di lunghezza + redazione PII; con `Ai__SelfCheckEnabled=true` il modello viene interrogato una seconda volta per dichiarare "questa risposta è coerente?". Disabilitato di default: su modelli piccoli (3B) il reviewer tende a bloccare risposte legittime; abilitare solo con modelli 8B+.
336336
- **Persistenza cifrata** di ogni interazione (`AiInteraction`, AES-256-GCM via `IFieldProtector`): l'utente può rileggere input + output dal proprio storico ("Cronologia AI" in Account), l'owner del cerchio vede le interazioni di cerchio degli altri membri (escluse le riflessioni personali).
337337
- **Feedback** 👍 / 👎 / 🚩 su ogni risposta, salvato come parte dell'interazione (utile per migliorare prompt e modello).
338338
- **Cache idempotency 1 h** per input identici nello stesso scope (utente+cerchio+funzione): risparmia chiamate e mostra `X-AI-Cache: hit` nella risposta.

backend/src/Accanto.Application/Ai/AiOptions.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,12 @@ public class AiOptions
3838
/// <summary>Cap finale sul testo della risposta (post-self-check). Taglio + ellipsis.</summary>
3939
public int MaxOutputChars { get; set; } = 2000;
4040

41-
/// <summary>Abilita il secondo passaggio LLM di verifica on-topic + safety.</summary>
42-
public bool SelfCheckEnabled { get; set; } = true;
41+
/// <summary>
42+
/// Abilita il secondo passaggio LLM di verifica on-topic + safety.
43+
/// Default false: su modelli piccoli (3B) il reviewer tende a marcare come fuori-tema
44+
/// risposte legittime di caregiving. Attivare solo con modelli 8B+ ben istruiti.
45+
/// </summary>
46+
public bool SelfCheckEnabled { get; set; } = false;
4347

4448
/// <summary>TTL (in minuti) della cache idempotency. 0 = disabilitata.</summary>
4549
public int CacheTtlMinutes { get; set; } = 60;

backend/src/Accanto.Application/Ai/Guardrails/OutputGuardrail.cs

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,30 +97,52 @@ private async Task<bool> SelfCheckAsync(string responseText, string language, IA
9797
{
9898
var isEn = string.Equals(language, "en", StringComparison.OrdinalIgnoreCase);
9999
var prompt = isEn
100-
? "You are a strict reviewer. Decide if the ASSISTANT REPLY below is strictly about family caregiving " +
101-
"and does NOT contain direct medical, legal or financial advice, instructions to perform code, off-topic content, " +
102-
"or anything unrelated to caregiving. Answer with ONE word: YES or NO. Nothing else.\n\n" +
100+
? "You review whether the ASSISTANT REPLY below is appropriate for a family-caregiving app. " +
101+
"It is APPROPRIATE if it talks about a sick or fragile relative, the caregiver's feelings, " +
102+
"updates to share with family, messages to or from doctors, daily care, logistics, emotional support. " +
103+
"It is INAPPROPRIATE only if it is clearly off-topic (politics, finance, programming, sexual content) " +
104+
"or contains explicit medical/legal/financial advice with prescriptions. " +
105+
"Reply with exactly one token: YES (appropriate) or NO (inappropriate). Default to YES if unsure.\n\n" +
103106
"ASSISTANT REPLY:\n" + responseText
104-
: "Sei un revisore severo. Decidi se la RISPOSTA DELL'ASSISTENTE qui sotto è strettamente sul caregiving familiare " +
105-
"e NON contiene pareri medici/legali/finanziari diretti, istruzioni di codice, contenuti fuori tema o non pertinenti. " +
106-
"Rispondi con UNA sola parola: SI oppure NO. Niente altro.\n\n" +
107+
: "Devi valutare se la RISPOSTA DELL'ASSISTENTE qui sotto è adeguata per un'app di caregiving familiare. " +
108+
"È ADEGUATA se parla di un familiare malato o fragile, dei sentimenti del caregiver, " +
109+
"di aggiornamenti da condividere con la famiglia, messaggi da/per i medici, cura quotidiana, logistica, supporto emotivo. " +
110+
"È INADEGUATA solo se è chiaramente fuori tema (politica, finanza, programmazione, contenuti sessuali) " +
111+
"o contiene pareri medici/legali/finanziari espliciti con prescrizioni. " +
112+
"Rispondi con un solo token: SI (adeguata) oppure NO (inadeguata). Se hai dubbi, rispondi SI.\n\n" +
107113
"RISPOSTA DELL'ASSISTENTE:\n" + responseText;
108114

109115
try
110116
{
111-
var verdict = await assistant.GenerateAsync(prompt, language, maxTokens: 4, cancellationToken: ct);
112-
var token = (verdict.Text ?? string.Empty).Trim().Trim('.', ',', '!', '?', '"', '\'', '`').ToLowerInvariant();
113-
// accetta "si"/"sì"/"yes" come pass; tutto il resto = fail (default deny)
114-
return token == "si" || token == "sì" || token == "yes";
117+
var verdict = await assistant.GenerateAsync(prompt, language, maxTokens: 8, cancellationToken: ct);
118+
var raw = (verdict.Text ?? string.Empty).Trim();
119+
// Estrai il primo "token alfabetico" (massimo 6 lettere) della risposta del reviewer.
120+
// Accetta come PASS qualsiasi inizio con sì/si/yes; come FAIL solo "no"/"non".
121+
// Default → PASS (i 3B sbagliano spesso il formato; meglio non bloccare l'utente).
122+
var first = new string(raw.TakeWhile(c => char.IsLetter(c) || c == 'ì' || c == 'Ì').Take(6).ToArray())
123+
.ToLowerInvariant();
124+
var passed = first.StartsWith("si") || first.StartsWith("sì") || first.StartsWith("yes");
125+
var failed = first == "no" || first == "non";
126+
if (failed)
127+
{
128+
_logger.LogInformation("AI self-check verdict NO (raw='{Raw}')", Truncate(raw, 60));
129+
return false;
130+
}
131+
if (!passed)
132+
{
133+
_logger.LogDebug("AI self-check ambiguous verdict (raw='{Raw}') → default PASS", Truncate(raw, 60));
134+
}
135+
return true;
115136
}
116137
catch (Exception ex)
117138
{
118139
_logger.LogWarning(ex, "Self-check LLM call failed; treating as pass to avoid blocking legitimate replies.");
119-
// policy: se il self-check è rotto (timeout/network), NON bloccare la risposta dell'utente
120140
return true;
121141
}
122142
}
123143

144+
private static string Truncate(string s, int max) => string.IsNullOrEmpty(s) || s.Length <= max ? s ?? string.Empty : s[..max] + "…";
145+
124146
public static string OutOfScopeMessage(string language)
125147
=> string.Equals(language, "en", StringComparison.OrdinalIgnoreCase)
126148
? "I can only help with family caregiving topics. Try reformulating your request around caregiving for a relative."

0 commit comments

Comments
 (0)