feat(ai): enhance AI trace warnings and reason codes system
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 4m21s
Test Suite / flutter-quality (push) Failing after 1m38s

- Added structured warning system with `AdminAiWarning` type in backend and Flutter
- Implemented detailed reason descriptors with `FlyerReasonDescriptor` for parse and match operations
- Added `legacyWarnings` field to maintain backward compatibility
- Enhanced AI trace service to collect and format warnings with item-level context
- Updated flyer import services to include detailed reason descriptions in responses
- Added Swedish diacritic preservation for cheese variants (Prästost, Herrgårdsost, Grevéost)
- Implemented UTF-8 content validation for AI responses
- Added new reason code definitions in `reason-codes.ts`
- Updated Flutter UI to display structured warnings with severity indicators
- Added error report generation and copy functionality in admin panel
- Added comprehensive test coverage for new warning system and cheese normalization

BREAKING CHANGE: AI trace warnings are now structured objects instead of simple strings
This commit is contained in:
Nils-Johan Gynther
2026-05-23 21:11:46 +02:00
parent 0fb507f247
commit d9f992ca9a
18 changed files with 1308 additions and 81 deletions
@@ -214,10 +214,11 @@ Regler:
6) Om en rubrik/lista innehaller flera kommaseparerade namn och efterfoljande rad/rader innehaller gemensam brand, vikt, pris eller kampanjvillkor: expandera till separata objekt (en per namn) och arv all gemensam metadata.
7) Tillämpa samma split-regel generellt for liknande tillbud (inte bara ost), nar listan tydligt representerar produktvarianter/smaker/sorter.
8) Splitta INTE om listan snarare ar ingredienser, avdelningar, eller otydlig marknadsforing utan tydlig produktvariant.
9) Specialregel ost: namn som PRAST/HERRGARD/GREVE ska normaliseras till Prastost/Herrgardsost/Greveost.
9) Specialregel ost: namn som PRAST/HERRGARD/GREVE ska normaliseras till Prästost/Herrgårdsost/Grevéost.
10) Om texten innehaller "ARLA KO" ska brand vara exakt "Arla Ko".
11) For ovan ostsorter ska category vara "Hardost".
12) Returnera aldrig extra nycklar, text, markdown eller forklaringar utanfor JSON-arrayen.
12) Behåll svenska diakritiska tecken (ä, å, ö, é) i produktnamn. Returnera "Prästost", "Herrgårdsost", "Grevéost" - inte ASCII-versioner.
13) Returnera aldrig extra nycklar, text, markdown eller forklaringar utanfor JSON-arrayen.
Exempel bundle utdata:
[
@@ -258,7 +259,7 @@ Input-idé: "PRAST, HERRGARD, GREVE" + "ARLA KO" + gemensam vikt/pris.
Output-idé:
[
{
"name": "Prastost",
"name": "Prästost",
"brand": "Arla Ko",
"category": "Hardost",
"isBundle": false,
@@ -271,7 +272,7 @@ Output-idé:
"offer": ["Max 3 forp/hushall"]
},
{
"name": "Herrgardsost",
"name": "Herrgårdsost",
"brand": "Arla Ko",
"category": "Hardost",
"isBundle": false,
@@ -358,7 +359,7 @@ ${truncatedText}`;
private normalizeName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-zåäö0-9\s]/g, '')
.replace(/[^a-zåäöé0-9\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
@@ -427,7 +428,7 @@ ${truncatedText}`;
'Mistral-anrop timeout',
);
const content = response.choices?.[0]?.message?.content;
const content = this.ensureUtf8Content(response.choices?.[0]?.message?.content);
if (!content) {
throw new BadRequestException('Tomt svar från AI-modellen.');
}
@@ -531,6 +532,40 @@ ${truncatedText}`;
return hasCampaignMarkers ? normalized : '';
}
private ensureUtf8Content(content: unknown): string {
const asString = this.flattenContent(content);
if (!asString) return '';
const utf8 = Buffer.from(asString, 'utf8').toString('utf8');
if (this.debugEnabled && (asString.includes('\uFFFD') || utf8.includes('\uFFFD'))) {
const hex = Buffer.from(asString, 'utf8').toString('hex').slice(0, 256);
this.logger.debug(`Potential encoding issue in AI response (hex preview): ${hex}`);
}
return utf8;
}
private flattenContent(content: unknown): string {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === 'string') return part;
if (part && typeof part === 'object' && 'text' in part) {
const text = (part as { text?: unknown }).text;
return typeof text === 'string' ? text : '';
}
return '';
})
.join('');
}
if (content == null) {
return '';
}
return String(content);
}
private readPositiveIntEnv(key: string, fallback: number): number {
const raw = process.env[key];
if (!raw) return fallback;