lot-11-E: corrections P2 (10 items) + autoresearch métriques artefact

## Corrections P2 — Code quality
- node-engine-runner.js: structuredClone + graph.edges validation
- http-api.js: input validation from-source + extension/mime check uploads
- chat-routing.js: regex compilées au module scope (perf)
- Chat.tsx: React.memo ChatMessage + validation couleurs persona
- sessions.js: saveAllSessions debounce 2s
- ollama.js: model token limits lookup (mistral/llama/qwen/phi/gemma)
- storage.js: warning session messages > 90% du max

## Autoresearch avancé
- Extraction score métier depuis artefacts d'évaluation (6 métriques)
- Score artefact prioritaire, fallback status-based
- TSV étendu avec colonne artifact_score
- Documentation mise à jour

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-16 16:13:56 +01:00
parent 18f489dbaf
commit b817985a19
9 changed files with 176 additions and 65 deletions
+60 -50
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useWebSocket } from "../hooks/useWebSocket";
const WS_URL = import.meta.env.VITE_WS_URL || "ws://127.0.0.1:3333";
@@ -22,6 +22,56 @@ const MAX_MESSAGES = 500;
const MAX_HISTORY = 100;
let msgIdCounter = 0;
interface ChatMessageProps {
msg: ChatMsg;
getNickColor: (nick: string) => string | undefined;
channel: string;
}
const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor, channel }: ChatMessageProps) {
switch (msg.type) {
case "system":
return (
<div className="chat-msg chat-msg-system">
{(msg.text || "").split("\n").map((line, i) => (
<div key={i}>{line || "\u00A0"}</div>
))}
</div>
);
case "join":
return (
<div className="chat-msg chat-msg-system">
{"--> "}{msg.nick} a rejoint {msg.channel || channel}
</div>
);
case "part":
return (
<div className="chat-msg chat-msg-system">
{"<-- "}{msg.nick} a quitte {msg.channel || channel}
</div>
);
case "message":
default: {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
const className = color ? "chat-msg chat-msg-persona" : "chat-msg chat-msg-user";
return (
<div
className={className}
style={color ? { color } : undefined}
>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
</div>
);
}
}
});
export default function Chat() {
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [users, setUsers] = useState<string[]>([]);
@@ -44,7 +94,10 @@ export default function Chat() {
switch (type) {
case "persona":
if (typeof msg.nick === "string" && typeof msg.color === "string") {
setPersonaColors((prev) => ({ ...prev, [msg.nick as string]: msg.color as string }));
const color = msg.color as string;
if (/^#[0-9a-fA-F]{3,8}$|^[a-z]{3,20}$/i.test(color)) {
setPersonaColors((prev) => ({ ...prev, [msg.nick as string]: color }));
}
}
return;
@@ -215,54 +268,9 @@ export default function Chat() {
}
}
function getNickColor(nick: string): string | undefined {
const getNickColor = useCallback((nick: string): string | undefined => {
return personaColors[nick];
}
function renderMessage(msg: ChatMsg) {
switch (msg.type) {
case "system":
return (
<div key={msg.id} className="chat-msg chat-msg-system">
{(msg.text || "").split("\n").map((line, i) => (
<div key={i}>{line || "\u00A0"}</div>
))}
</div>
);
case "join":
return (
<div key={msg.id} className="chat-msg chat-msg-system">
{"--> "}{msg.nick} a rejoint {msg.channel || channel}
</div>
);
case "part":
return (
<div key={msg.id} className="chat-msg chat-msg-system">
{"<-- "}{msg.nick} a quitte {msg.channel || channel}
</div>
);
case "message":
default: {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
const className = color ? "chat-msg chat-msg-persona" : "chat-msg chat-msg-user";
return (
<div
key={msg.id}
className={className}
style={color ? { color } : undefined}
>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
</div>
);
}
}
}
}, [personaColors]);
return (
<div className="chat-container">
@@ -275,7 +283,9 @@ export default function Chat() {
<div className="chat-body">
<div className="chat-messages" ref={messagesContainerRef}>
{messages.map(renderMessage)}
{messages.map((msg) => (
<ChatMessage key={msg.id} msg={msg} getNickColor={getNickColor} channel={channel} />
))}
<div ref={messagesEndRef} />
</div>
+5 -5
View File
@@ -1,3 +1,6 @@
const POSITIVE_PREFERENCE_RE = /bien vu|exactement|merci|bravo|oui|correct|t'as raison|bien dit|parfait|yes|nice|good/i;
const NEGATIVE_PREFERENCE_RE = /non|faux|n'importe quoi|nawak|wrong|pas du tout|incorrect/i;
function createChatRouter({
admins,
contextMaxMessages,
@@ -51,15 +54,12 @@ function createChatRouter({
const round = lastRoundResponses.get(channel);
if (!round || round.length < 2) return null;
const positivePatterns = /bien vu|exactement|merci|bravo|oui|correct|t'as raison|bien dit|parfait|yes|nice|good/i;
const negativePatterns = /non|faux|n'importe quoi|nawak|wrong|pas du tout|incorrect/i;
for (const response of round) {
const nameMentioned = text.toLowerCase().includes(response.botNick.toLowerCase());
if (nameMentioned && positivePatterns.test(text)) {
if (nameMentioned && POSITIVE_PREFERENCE_RE.test(text)) {
return { chosen: response, type: "implicit_positive" };
}
if (nameMentioned && negativePatterns.test(text)) {
if (nameMentioned && NEGATIVE_PREFERENCE_RE.test(text)) {
return { rejected: response, type: "implicit_negative" };
}
}
+20 -3
View File
@@ -53,12 +53,29 @@ Le score par defaut est derive du statut terminal, avec bonus de vitesse pour le
La table statusScores du JSON permet d'ajuster le comportement sans changer le script.
## Score metier via artefacts
Le script extrait automatiquement les scores depuis les artefacts d'evaluation du run.
Les metriques supportees (par ordre de priorite) :
- `score` — score generique (0-1)
- `eval_score` — score d'evaluation
- `accuracy` — precision
- `f1` — F1 score
- `bleu` — score BLEU (traduction/generation)
- `perplexity` — perplexite (inversee: 1/(1+p), plus bas = mieux)
Pour qu'un run produise un score metier, le graph doit inclure un node `benchmark` ou `prompt_test` qui ecrit un artefact de type `evaluation` avec une de ces metriques dans le champ `data`.
Si aucun artefact d'evaluation n'est trouve, le fallback est le score base sur le statut terminal.
## Limites actuelles
- pas encore de metriques metier (qualite persona, cout tokens, latence p95)
- keep/discard est une decision de session, pas encore un alias model registry
- pas de mutation automatique des graphes ou hyperparametres
## Etape suivante recommandee
## Etapes suivantes
Ajouter un node d'evaluation canonique qui produit un score metier dans un artefact persiste, puis brancher ce score comme source principale de decision dans la boucle autoresearch.
1. Brancher keep/discard comme alias dans le model registry (register_model node)
2. Ajouter mutation automatique des hyperparametres entre experiments
3. Integrer les metriques cout tokens et latence p95
+29 -1
View File
@@ -339,6 +339,25 @@ function registerApiRoutes(app, {
});
});
function validateExtensionMime(fileName, mime) {
const ext = path.extname(fileName).toLowerCase();
const extMimeMap = {
".jpg": "image/", ".jpeg": "image/", ".png": "image/", ".gif": "image/",
".webp": "image/", ".svg": "image/", ".bmp": "image/", ".ico": "image/",
".mp3": "audio/", ".wav": "audio/", ".ogg": "audio/", ".flac": "audio/",
".m4a": "audio/", ".aac": "audio/",
".txt": "text/", ".html": "text/", ".css": "text/", ".xml": "text/",
".pdf": "application/pdf",
".json": "application/json",
".csv": "text/csv",
".jsonl": "application/x-ndjson",
};
const expected = extMimeMap[ext];
if (!expected) return true; // unknown extension — allow
if (expected.endsWith("/")) return mime.startsWith(expected);
return mime === expected;
}
// SEC-03 fix: Attachment endpoints require network auth
app.post("/api/chat/attachments", requireAdminNetwork, async (req, res) => {
try {
@@ -353,6 +372,11 @@ function registerApiRoutes(app, {
.split(";")[0]
.trim()
.toLowerCase();
if (!validateExtensionMime(fileName, mime)) {
return res.status(400).json({ error: `File extension does not match mime type: ${fileName} / ${mime}` });
}
const buffer = await readRequestBuffer(req, MAX_UPLOAD_BYTES);
const attachment = await attachmentService.ingestAttachment({
@@ -457,7 +481,11 @@ function registerApiRoutes(app, {
app.post("/api/admin/personas/from-source", requireAdmin, async (req, res) => {
try {
const result = createPersonaFromSource(req.body || {});
const body = req.body || {};
if (!body.nick && !body.name) {
return res.status(400).json({ error: "nick or name is required" });
}
const result = createPersonaFromSource(body);
const source = updatePersonaSource(result.persona.id, {
subjectName: req.body?.subjectName || req.body?.name || result.persona.name,
query: req.body?.query,
+2 -1
View File
@@ -15,7 +15,7 @@ function isObject(value) {
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
return structuredClone(value);
}
function cleanText(value, maxLength = 400) {
@@ -166,6 +166,7 @@ function normalizeMessagesRow(row) {
}
function topologicalSort(graph) {
if (!Array.isArray(graph.edges)) graph.edges = [];
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
const incoming = new Map(graph.nodes.map((node) => [node.id, 0]));
const outgoing = new Map(graph.nodes.map((node) => [node.id, []]));
+12 -1
View File
@@ -64,7 +64,18 @@ function createOllamaClient({
}
}
const numPredict = tokenLimit || (model.includes("mistral") ? maxResponseTokensSmall : maxResponseTokens);
const MODEL_TOKEN_LIMITS = {
mistral: 8192,
llama: 4096,
qwen: 8192,
phi: 4096,
gemma: 8192,
};
let numPredict = tokenLimit;
if (!numPredict) {
const match = Object.entries(MODEL_TOKEN_LIMITS).find(([key]) => model.toLowerCase().includes(key));
numPredict = match ? Math.min(match[1], maxResponseTokensSmall) : maxResponseTokens;
}
let fullResponse = "";
+39 -4
View File
@@ -67,7 +67,13 @@ function loadConfig(configPath) {
};
}
function scoreRun(status, elapsedMs, statusScores) {
function scoreRun(status, elapsedMs, statusScores, artifactScore) {
// If an artifact-based metric score exists, use it as primary
if (Number.isFinite(artifactScore) && artifactScore > 0) {
const speedBonus = Math.max(0, 1 - elapsedMs / (10 * 60 * 1000));
return artifactScore + speedBonus * 0.1;
}
// Fallback: status-based scoring
const base = Number(statusScores[status]);
const safeBase = Number.isFinite(base) ? base : -1;
const speedBonus = safeBase > 0 ? Math.max(0, 1 - elapsedMs / (10 * 60 * 1000)) : 0;
@@ -81,7 +87,7 @@ function ensureTsvHeader(filePath) {
fs.writeFileSync(
filePath,
[
"timestamp\texperiment\trun_id\tgraph_id\tstatus\telapsed_ms\tscore\tdecision\ttag",
"timestamp\texperiment\trun_id\tgraph_id\tstatus\telapsed_ms\tartifact_score\tscore\tdecision\ttag",
].join("\n") + "\n",
"utf8",
);
@@ -92,6 +98,32 @@ function appendTsv(filePath, row) {
fs.appendFileSync(filePath, row.join("\t") + "\n", "utf8");
}
async function extractArtifactScore(pool, runId) {
// Look for evaluation artifacts that contain a metric score
try {
const result = await pool.query(
`SELECT data FROM node_run_artifacts
WHERE run_id = $1 AND type = 'evaluation'
ORDER BY created_at DESC LIMIT 1`,
[runId],
);
if (result.rows.length === 0) return NaN;
const data = result.rows[0].data;
if (!data || typeof data !== "object") return NaN;
// Support multiple metric names: score, eval_score, accuracy, f1, bleu
for (const key of ["score", "eval_score", "accuracy", "f1", "bleu", "perplexity"]) {
if (Number.isFinite(data[key])) {
// For perplexity, lower is better — invert it
if (key === "perplexity") return 1 / (1 + data[key]);
return data[key];
}
}
return NaN;
} catch {
return NaN;
}
}
async function ensureGraphExists(pool, graphId) {
const result = await pool.query(
"SELECT id, name FROM node_graphs WHERE id = $1 LIMIT 1",
@@ -202,7 +234,8 @@ async function main() {
config.pollIntervalMs,
);
const score = scoreRun(outcome.status, outcome.elapsedMs, config.statusScores);
const artifactScore = await extractArtifactScore(pool, run.id);
const score = scoreRun(outcome.status, outcome.elapsedMs, config.statusScores, artifactScore);
const isBest = score > bestScore;
const decision = isBest ? "keep" : "discard";
@@ -218,15 +251,17 @@ async function main() {
config.graphId,
outcome.status,
String(outcome.elapsedMs),
Number.isFinite(artifactScore) ? artifactScore.toFixed(6) : "",
score.toFixed(6),
decision,
config.tag,
]);
const suffix = outcome.timedOut ? " timeout" : "";
const metricInfo = Number.isFinite(artifactScore) ? " metric=" + artifactScore.toFixed(4) : "";
console.log(
"[autoresearch] " + run.id + " status=" + outcome.status +
" score=" + score.toFixed(4) + " decision=" + decision + suffix,
metricInfo + " score=" + score.toFixed(4) + " decision=" + decision + suffix,
);
}
+5
View File
@@ -84,7 +84,12 @@ function createSessionManager({
intervalIds.length = 0;
}
let _lastSaveAllTs = 0;
function saveAllSessions() {
const now = Date.now();
if (now - _lastSaveAllTs < 2000) return;
_lastSaveAllTs = now;
for (const [id, session] of sessions) {
saveSession(id, session);
}
+4
View File
@@ -268,6 +268,10 @@ function createStorage(dataDir, {
.slice(-MAX_SESSION_MESSAGES)
: [];
if (Array.isArray(session.messages) && session.messages.length > MAX_SESSION_MESSAGES * 0.9) {
console.warn(`[storage] Session approaching message limit: ${session.messages.length}/${MAX_SESSION_MESSAGES} messages`);
}
return {
model: cleanText(session.model, 120) || null,
persona: cleanText(session.persona, 120) || null,