Spaces:
Sleeping
Sleeping
Update
Browse files- server/aiCore.js +17 -10
- server/aiMixMiddleware.ts +12 -5
- src/components/lab/ResultsPanel.tsx +14 -6
- src/pages/Lab.tsx +1 -1
- src/services/aiMix.ts +4 -3
server/aiCore.js
CHANGED
|
@@ -20,9 +20,9 @@ function parseModelList(env) {
|
|
| 20 |
|
| 21 |
const fromList = rawList
|
| 22 |
? rawList
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
: [];
|
| 27 |
|
| 28 |
const seed = fromList.length > 0 ? fromList : single ? [single] : [];
|
|
@@ -62,24 +62,28 @@ function countSentences(text) {
|
|
| 62 |
|
| 63 |
function validateObservation({ text, temperatureC, hasHazard }) {
|
| 64 |
const t = (text || "").trim();
|
| 65 |
-
|
| 66 |
if (!/^Observation\s*:/i.test(t)) return { ok: false, reason: "Ne commence pas par Observation:" };
|
| 67 |
if (!/Equation\s*:/i.test(t)) return { ok: false, reason: "equation manquante (Equation:)" };
|
| 68 |
-
|
|
|
|
| 69 |
const parts = t.split(/Equation\s*:/i);
|
| 70 |
const observation = parts[0].replace(/^Observation\s*:/i, '').trim();
|
| 71 |
-
const
|
|
|
|
|
|
|
| 72 |
|
| 73 |
if (observation.length < 10) return { ok: false, reason: "observation trop courte" };
|
| 74 |
const sentenceCount = countSentences(observation);
|
| 75 |
if (sentenceCount > 5) return { ok: false, reason: `observation trop longue (${sentenceCount} phrases)` };
|
| 76 |
if (equation.length < 2) return { ok: false, reason: "equation invalide" };
|
|
|
|
| 77 |
|
| 78 |
if (hasHazard && !/secur|prud|attention|protection|danger|gants|lunettes|corros/i.test(observation)) {
|
| 79 |
// on peut ne pas bloquer rigidement mais le logguer, ici on accepte
|
| 80 |
}
|
| 81 |
|
| 82 |
-
return { ok: true, reason: null, parsed: { observation, equation } };
|
| 83 |
}
|
| 84 |
|
| 85 |
function normalizeOneParagraph(text) {
|
|
@@ -105,9 +109,11 @@ function buildPrompt({ temperatureC, substances }) {
|
|
| 105 |
"Tu es un assistant de laboratoire de chimie. " +
|
| 106 |
"Decris l'observation du melange en etant TRES CONCIS (MAXIMUM 2 a 3 phrases), en allant droit au but et en enlevant tous les details inutiles. Inclus les rappels de securite si necessaire. " +
|
| 107 |
"Donne ensuite l'equation correspondante. " +
|
|
|
|
| 108 |
"Important: Tu dois repondre UNIQUEMENT sous ce format textuel exact (sans markdown, sans blabla):\n" +
|
| 109 |
"Observation: <tes 2 a 3 phrases ici>\n" +
|
| 110 |
-
"Equation: <equation chimique avec -> ou -> ou →>"
|
|
|
|
| 111 |
|
| 112 |
const user = [
|
| 113 |
"Contexte:",
|
|
@@ -117,7 +123,8 @@ function buildPrompt({ temperatureC, substances }) {
|
|
| 117 |
"",
|
| 118 |
"Format attendu strictement: ",
|
| 119 |
"Observation: <...>",
|
| 120 |
-
"Equation: <...>"
|
|
|
|
| 121 |
].join("\n");
|
| 122 |
|
| 123 |
return { system, user };
|
|
@@ -259,7 +266,7 @@ async function callGroqChatCompletionsText({ apiKey, model, system, user }) {
|
|
| 259 |
// Some models may place the final answer into "reasoning". Accept it only if it matches our constraints.
|
| 260 |
const candidate = normalizeOneParagraph(msg.reasoning);
|
| 261 |
// Full validation is done later with real temp/hazard. Here we only ensure it looks like a final answer.
|
| 262 |
-
if (/equation\s*:/i.test(candidate) && /
|
| 263 |
return candidate;
|
| 264 |
}
|
| 265 |
throw new UpstreamError(422, "Model returned reasoning without final content");
|
|
|
|
| 20 |
|
| 21 |
const fromList = rawList
|
| 22 |
? rawList
|
| 23 |
+
.split(",")
|
| 24 |
+
.map((s) => s.trim())
|
| 25 |
+
.filter(Boolean)
|
| 26 |
: [];
|
| 27 |
|
| 28 |
const seed = fromList.length > 0 ? fromList : single ? [single] : [];
|
|
|
|
| 62 |
|
| 63 |
function validateObservation({ text, temperatureC, hasHazard }) {
|
| 64 |
const t = (text || "").trim();
|
| 65 |
+
|
| 66 |
if (!/^Observation\s*:/i.test(t)) return { ok: false, reason: "Ne commence pas par Observation:" };
|
| 67 |
if (!/Equation\s*:/i.test(t)) return { ok: false, reason: "equation manquante (Equation:)" };
|
| 68 |
+
if (!/Application\s*:/i.test(t)) return { ok: false, reason: "application manquante (Application:)" };
|
| 69 |
+
|
| 70 |
const parts = t.split(/Equation\s*:/i);
|
| 71 |
const observation = parts[0].replace(/^Observation\s*:/i, '').trim();
|
| 72 |
+
const lowerParts = parts[1].split(/Application\s*:/i);
|
| 73 |
+
const equation = lowerParts[0].trim();
|
| 74 |
+
const application = lowerParts[1] ? lowerParts[1].trim() : "";
|
| 75 |
|
| 76 |
if (observation.length < 10) return { ok: false, reason: "observation trop courte" };
|
| 77 |
const sentenceCount = countSentences(observation);
|
| 78 |
if (sentenceCount > 5) return { ok: false, reason: `observation trop longue (${sentenceCount} phrases)` };
|
| 79 |
if (equation.length < 2) return { ok: false, reason: "equation invalide" };
|
| 80 |
+
if (application.length < 10) return { ok: false, reason: "application invalide ou trop courte" };
|
| 81 |
|
| 82 |
if (hasHazard && !/secur|prud|attention|protection|danger|gants|lunettes|corros/i.test(observation)) {
|
| 83 |
// on peut ne pas bloquer rigidement mais le logguer, ici on accepte
|
| 84 |
}
|
| 85 |
|
| 86 |
+
return { ok: true, reason: null, parsed: { observation, equation, application } };
|
| 87 |
}
|
| 88 |
|
| 89 |
function normalizeOneParagraph(text) {
|
|
|
|
| 109 |
"Tu es un assistant de laboratoire de chimie. " +
|
| 110 |
"Decris l'observation du melange en etant TRES CONCIS (MAXIMUM 2 a 3 phrases), en allant droit au but et en enlevant tous les details inutiles. Inclus les rappels de securite si necessaire. " +
|
| 111 |
"Donne ensuite l'equation correspondante. " +
|
| 112 |
+
"Termine avec une application pratique de ce melange dans la vie courante ou l'industrie (ex: 'ce melange sert a la fabrication de detergent'). " +
|
| 113 |
"Important: Tu dois repondre UNIQUEMENT sous ce format textuel exact (sans markdown, sans blabla):\n" +
|
| 114 |
"Observation: <tes 2 a 3 phrases ici>\n" +
|
| 115 |
+
"Equation: <equation chimique avec -> ou -> ou →>\n" +
|
| 116 |
+
"Application: <1 a 2 phrases sur l'utilite pratique>";
|
| 117 |
|
| 118 |
const user = [
|
| 119 |
"Contexte:",
|
|
|
|
| 123 |
"",
|
| 124 |
"Format attendu strictement: ",
|
| 125 |
"Observation: <...>",
|
| 126 |
+
"Equation: <...>",
|
| 127 |
+
"Application: <...>"
|
| 128 |
].join("\n");
|
| 129 |
|
| 130 |
return { system, user };
|
|
|
|
| 266 |
// Some models may place the final answer into "reasoning". Accept it only if it matches our constraints.
|
| 267 |
const candidate = normalizeOneParagraph(msg.reasoning);
|
| 268 |
// Full validation is done later with real temp/hazard. Here we only ensure it looks like a final answer.
|
| 269 |
+
if (/equation\s*:/i.test(candidate) && /application\s*:/i.test(candidate) && /(->|→)/.test(candidate)) {
|
| 270 |
return candidate;
|
| 271 |
}
|
| 272 |
throw new UpstreamError(422, "Model returned reasoning without final content");
|
server/aiMixMiddleware.ts
CHANGED
|
@@ -65,9 +65,11 @@ function buildPrompt(args: {
|
|
| 65 |
"Tu es un assistant de laboratoire de chimie. " +
|
| 66 |
"Decris l'observation du melange en etant TRES CONCIS (MAXIMUM 2 a 3 phrases), en allant droit au but et en enlevant tous les details inutiles. Inclus les rappels de securite si necessaire. " +
|
| 67 |
"Donne ensuite l'equation correspondante. " +
|
|
|
|
| 68 |
"Important: Tu dois repondre UNIQUEMENT sous ce format textuel exact (sans markdown, sans blabla):\n" +
|
| 69 |
"Observation: <tes 2 a 3 phrases ici>\n" +
|
| 70 |
-
"Equation: <equation chimique avec -> ou -> ou →>"
|
|
|
|
| 71 |
|
| 72 |
const user =
|
| 73 |
[
|
|
@@ -78,7 +80,8 @@ function buildPrompt(args: {
|
|
| 78 |
"",
|
| 79 |
"Format attendu strictement: ",
|
| 80 |
"Observation: <...>",
|
| 81 |
-
"Equation: <...>"
|
|
|
|
| 82 |
].join("\n");
|
| 83 |
|
| 84 |
return { system, user };
|
|
@@ -106,21 +109,25 @@ function validateObservation(args: {
|
|
| 106 |
|
| 107 |
if (!/^Observation\s*:/i.test(t)) return { ok: false, reason: "Ne commence pas par Observation:" };
|
| 108 |
if (!/Equation\s*:/i.test(t)) return { ok: false, reason: "equation manquante (Equation:)" };
|
|
|
|
| 109 |
|
| 110 |
const parts = t.split(/Equation\s*:/i);
|
| 111 |
const observation = parts[0].replace(/^Observation\s*:/i, '').trim();
|
| 112 |
-
const
|
|
|
|
|
|
|
| 113 |
|
| 114 |
if (observation.length < 10) return { ok: false, reason: "observation trop courte" };
|
| 115 |
const sentenceCount = countSentences(observation);
|
| 116 |
if (sentenceCount > 5) return { ok: false, reason: `observation trop longue (${sentenceCount} phrases)` };
|
| 117 |
if (equation.length < 2) return { ok: false, reason: "equation invalide" };
|
|
|
|
| 118 |
|
| 119 |
if (args.hasHazard && !/secur|prud|attention|protection|danger|gants|lunettes|corros/i.test(observation)) {
|
| 120 |
// on l'accepte tout de meme
|
| 121 |
}
|
| 122 |
|
| 123 |
-
return { ok: true, reason: null, parsed: { observation, equation } };
|
| 124 |
}
|
| 125 |
|
| 126 |
async function httpsPostJson(args: {
|
|
@@ -282,7 +289,7 @@ async function callGroqChatCompletionsText(args: {
|
|
| 282 |
const reasoning = msg?.reasoning;
|
| 283 |
if (typeof reasoning === "string" && reasoning.trim()) {
|
| 284 |
const candidate = normalizeOneParagraph(reasoning);
|
| 285 |
-
if (/equation\s*:/i.test(candidate) && /(->|→)/.test(candidate)) {
|
| 286 |
return candidate;
|
| 287 |
}
|
| 288 |
throw new UpstreamError(422, "Model returned reasoning without final content");
|
|
|
|
| 65 |
"Tu es un assistant de laboratoire de chimie. " +
|
| 66 |
"Decris l'observation du melange en etant TRES CONCIS (MAXIMUM 2 a 3 phrases), en allant droit au but et en enlevant tous les details inutiles. Inclus les rappels de securite si necessaire. " +
|
| 67 |
"Donne ensuite l'equation correspondante. " +
|
| 68 |
+
"Termine avec une application pratique de ce melange dans la vie courante ou l'industrie (ex: 'ce melange sert a la fabrication de detergent'). " +
|
| 69 |
"Important: Tu dois repondre UNIQUEMENT sous ce format textuel exact (sans markdown, sans blabla):\n" +
|
| 70 |
"Observation: <tes 2 a 3 phrases ici>\n" +
|
| 71 |
+
"Equation: <equation chimique avec -> ou -> ou →>\n" +
|
| 72 |
+
"Application: <1 a 2 phrases sur l'utilite pratique>";
|
| 73 |
|
| 74 |
const user =
|
| 75 |
[
|
|
|
|
| 80 |
"",
|
| 81 |
"Format attendu strictement: ",
|
| 82 |
"Observation: <...>",
|
| 83 |
+
"Equation: <...>",
|
| 84 |
+
"Application: <...>"
|
| 85 |
].join("\n");
|
| 86 |
|
| 87 |
return { system, user };
|
|
|
|
| 109 |
|
| 110 |
if (!/^Observation\s*:/i.test(t)) return { ok: false, reason: "Ne commence pas par Observation:" };
|
| 111 |
if (!/Equation\s*:/i.test(t)) return { ok: false, reason: "equation manquante (Equation:)" };
|
| 112 |
+
if (!/Application\s*:/i.test(t)) return { ok: false, reason: "application manquante (Application:)" };
|
| 113 |
|
| 114 |
const parts = t.split(/Equation\s*:/i);
|
| 115 |
const observation = parts[0].replace(/^Observation\s*:/i, '').trim();
|
| 116 |
+
const lowerParts = parts[1].split(/Application\s*:/i);
|
| 117 |
+
const equation = lowerParts[0].trim();
|
| 118 |
+
const application = lowerParts[1] ? lowerParts[1].trim() : "";
|
| 119 |
|
| 120 |
if (observation.length < 10) return { ok: false, reason: "observation trop courte" };
|
| 121 |
const sentenceCount = countSentences(observation);
|
| 122 |
if (sentenceCount > 5) return { ok: false, reason: `observation trop longue (${sentenceCount} phrases)` };
|
| 123 |
if (equation.length < 2) return { ok: false, reason: "equation invalide" };
|
| 124 |
+
if (application.length < 10) return { ok: false, reason: "application invalide ou trop courte" };
|
| 125 |
|
| 126 |
if (args.hasHazard && !/secur|prud|attention|protection|danger|gants|lunettes|corros/i.test(observation)) {
|
| 127 |
// on l'accepte tout de meme
|
| 128 |
}
|
| 129 |
|
| 130 |
+
return { ok: true, reason: null, parsed: { observation, equation, application } };
|
| 131 |
}
|
| 132 |
|
| 133 |
async function httpsPostJson(args: {
|
|
|
|
| 289 |
const reasoning = msg?.reasoning;
|
| 290 |
if (typeof reasoning === "string" && reasoning.trim()) {
|
| 291 |
const candidate = normalizeOneParagraph(reasoning);
|
| 292 |
+
if (/equation\s*:/i.test(candidate) && /application\s*:/i.test(candidate) && /(->|→)/.test(candidate)) {
|
| 293 |
return candidate;
|
| 294 |
}
|
| 295 |
throw new UpstreamError(422, "Model returned reasoning without final content");
|
src/components/lab/ResultsPanel.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
|
| 4 |
import { Trash2, Download } from "lucide-react";
|
| 5 |
|
| 6 |
interface ResultsPanelProps {
|
| 7 |
-
results: { observation: string; equation: string }[];
|
| 8 |
onClear: () => void;
|
| 9 |
}
|
| 10 |
|
|
@@ -18,7 +18,7 @@ const ResultsPanel = ({ results, onClear }: ResultsPanelProps) => {
|
|
| 18 |
const headers = ['#', 'Observation', 'Heure'];
|
| 19 |
const rows = results.map((result, index) => [
|
| 20 |
index + 1,
|
| 21 |
-
`"${result.observation.replace(/"/g, '""')} [Eq: ${result.equation.replace(/"/g, '""')}]"`, // Escape quotes in CSV
|
| 22 |
new Date().toLocaleTimeString('fr-FR', {
|
| 23 |
hour: '2-digit',
|
| 24 |
minute: '2-digit',
|
|
@@ -86,8 +86,8 @@ const ResultsPanel = ({ results, onClear }: ResultsPanelProps) => {
|
|
| 86 |
<div className="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold flex-shrink-0">
|
| 87 |
{index + 1}
|
| 88 |
</div>
|
| 89 |
-
<div className="flex-1 min-w-0">
|
| 90 |
-
<p className="text-sm leading-relaxed">{result.observation}</p>
|
| 91 |
<p className="text-xs text-muted-foreground mt-1">
|
| 92 |
{new Date().toLocaleTimeString('fr-FR', {
|
| 93 |
hour: '2-digit',
|
|
@@ -98,13 +98,21 @@ const ResultsPanel = ({ results, onClear }: ResultsPanelProps) => {
|
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
{result.equation && (
|
| 101 |
-
<div className="mt-1 p-2 bg-muted/50 rounded-md border border-border/50">
|
| 102 |
<p className="text-[10px] font-semibold text-muted-foreground mb-1 uppercase tracking-wider">Équation chimique</p>
|
| 103 |
-
<p className="text-sm font-mono text-primary font-medium
|
| 104 |
{result.equation}
|
| 105 |
</p>
|
| 106 |
</div>
|
| 107 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</div>
|
| 109 |
))}
|
| 110 |
</div>
|
|
|
|
| 4 |
import { Trash2, Download } from "lucide-react";
|
| 5 |
|
| 6 |
interface ResultsPanelProps {
|
| 7 |
+
results: { observation: string; equation: string; application: string }[];
|
| 8 |
onClear: () => void;
|
| 9 |
}
|
| 10 |
|
|
|
|
| 18 |
const headers = ['#', 'Observation', 'Heure'];
|
| 19 |
const rows = results.map((result, index) => [
|
| 20 |
index + 1,
|
| 21 |
+
`"${result.observation.replace(/"/g, '""')} [Eq: ${result.equation.replace(/"/g, '""')}] [App: ${(result.application || '').replace(/"/g, '""')}]"`, // Escape quotes in CSV
|
| 22 |
new Date().toLocaleTimeString('fr-FR', {
|
| 23 |
hour: '2-digit',
|
| 24 |
minute: '2-digit',
|
|
|
|
| 86 |
<div className="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold flex-shrink-0">
|
| 87 |
{index + 1}
|
| 88 |
</div>
|
| 89 |
+
<div className="flex-1 min-w-0 break-words">
|
| 90 |
+
<p className="text-sm leading-relaxed break-words">{result.observation}</p>
|
| 91 |
<p className="text-xs text-muted-foreground mt-1">
|
| 92 |
{new Date().toLocaleTimeString('fr-FR', {
|
| 93 |
hour: '2-digit',
|
|
|
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
{result.equation && (
|
| 101 |
+
<div className="mt-1 p-2 bg-muted/50 rounded-md border border-border/50 w-full">
|
| 102 |
<p className="text-[10px] font-semibold text-muted-foreground mb-1 uppercase tracking-wider">Équation chimique</p>
|
| 103 |
+
<p className="text-sm font-mono text-primary font-medium break-words overflow-wrap-anywhere">
|
| 104 |
{result.equation}
|
| 105 |
</p>
|
| 106 |
</div>
|
| 107 |
)}
|
| 108 |
+
{result.application && (
|
| 109 |
+
<div className="mt-1 p-2 bg-primary/5 rounded-md border border-primary/20 w-full">
|
| 110 |
+
<p className="text-[10px] font-semibold text-primary/80 mb-1 uppercase tracking-wider">Application pratique</p>
|
| 111 |
+
<p className="text-sm text-foreground/90 break-words">
|
| 112 |
+
{result.application}
|
| 113 |
+
</p>
|
| 114 |
+
</div>
|
| 115 |
+
)}
|
| 116 |
</div>
|
| 117 |
))}
|
| 118 |
</div>
|
src/pages/Lab.tsx
CHANGED
|
@@ -20,7 +20,7 @@ const STORAGE_KEY = 'lab-workspaces';
|
|
| 20 |
|
| 21 |
const Lab = () => {
|
| 22 |
const [selectedItem, setSelectedItem] = useState<string | null>(null);
|
| 23 |
-
const [results, setResults] = useState<{ observation: string; equation: string }[]>([]);
|
| 24 |
const [safetyWarning, setSafetyWarning] = useState<string | null>(null);
|
| 25 |
const [temperatureC, setTemperatureC] = useState<number>(20);
|
| 26 |
const canvasRef = useRef<{ getSnapshot: () => string | null; loadSnapshot: (data: string) => void } | null>(null);
|
|
|
|
| 20 |
|
| 21 |
const Lab = () => {
|
| 22 |
const [selectedItem, setSelectedItem] = useState<string | null>(null);
|
| 23 |
+
const [results, setResults] = useState<{ observation: string; equation: string; application: string }[]>([]);
|
| 24 |
const [safetyWarning, setSafetyWarning] = useState<string | null>(null);
|
| 25 |
const [temperatureC, setTemperatureC] = useState<number>(20);
|
| 26 |
const canvasRef = useRef<{ getSnapshot: () => string | null; loadSnapshot: (data: string) => void } | null>(null);
|
src/services/aiMix.ts
CHANGED
|
@@ -10,7 +10,7 @@ export async function generateAiMixObservation(args: {
|
|
| 10 |
temperatureC: number;
|
| 11 |
// Back-compat (dev): optional ids
|
| 12 |
substanceIds?: string[];
|
| 13 |
-
}): Promise<{ observation: string; equation: string }> {
|
| 14 |
const resp = await fetch("/api/ai/mix", {
|
| 15 |
method: "POST",
|
| 16 |
headers: { "Content-Type": "application/json" },
|
|
@@ -33,11 +33,12 @@ export async function generateAiMixObservation(args: {
|
|
| 33 |
|
| 34 |
const data: any = await resp.json();
|
| 35 |
const out = data?.text;
|
| 36 |
-
if (!out || !out.observation || !out.equation) {
|
| 37 |
throw new Error("Reponse IA invalide");
|
| 38 |
}
|
| 39 |
return {
|
| 40 |
observation: out.observation.trim(),
|
| 41 |
-
equation: out.equation.trim()
|
|
|
|
| 42 |
};
|
| 43 |
}
|
|
|
|
| 10 |
temperatureC: number;
|
| 11 |
// Back-compat (dev): optional ids
|
| 12 |
substanceIds?: string[];
|
| 13 |
+
}): Promise<{ observation: string; equation: string; application: string }> {
|
| 14 |
const resp = await fetch("/api/ai/mix", {
|
| 15 |
method: "POST",
|
| 16 |
headers: { "Content-Type": "application/json" },
|
|
|
|
| 33 |
|
| 34 |
const data: any = await resp.json();
|
| 35 |
const out = data?.text;
|
| 36 |
+
if (!out || !out.observation || !out.equation || !out.application) {
|
| 37 |
throw new Error("Reponse IA invalide");
|
| 38 |
}
|
| 39 |
return {
|
| 40 |
observation: out.observation.trim(),
|
| 41 |
+
equation: out.equation.trim(),
|
| 42 |
+
application: out.application.trim()
|
| 43 |
};
|
| 44 |
}
|