feat(ai): enhance AI trace warnings and reason codes system
- 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user