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:
@@ -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
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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, []]));
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user