scripts/annotate.ts
Annotation non disponible
Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.
Code source· typescript· tronqué à 200 lignes sur 415
/**
* Story 1.3 — Annotation Gemini API avec ROTATION DE MODÈLES
*
* Stratégie : 4 modèles Gemini en round-robin. Quand un hit son rate limit,
* mark en cooldown 60s et passe au suivant. Tous en cooldown → wait shortest.
*
* Avantage : ~4x le débit du free tier (chaque modèle a son propre quota).
*
* Usage :
* npm run annotate -- --dry-run # estime le coût
* npm run annotate -- --limit 10 # test sur 10 fichiers
* npm run annotate # annote tout
*/
import { GoogleGenAI } from "@google/genai";
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
appendFileSync,
} from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { performance } from "node:perf_hooks";
import { getProject, graphFileName, annotationsFileName, PROJECTS } from "../lib/projects";
// --- Load .env.local ---
function loadEnvLocal(): void {
const envPath = join(process.cwd(), ".env.local");
if (!existsSync(envPath)) return;
const content = readFileSync(envPath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, "");
if (!process.env[key]) process.env[key] = value;
}
}
// --- Configuration ---
const MAX_LINES_INPUT = 100;
const SAVE_INTERVAL = 10;
const EUR_USD_RATE = 0.92;
// Cooldown quand un modèle hit rate limit (60s suffisent pour reset le quota minute)
const COOLDOWN_AFTER_429_MS = 60_000;
// Petit délai entre requêtes pour ne pas spam
const DELAY_BETWEEN_REQUESTS_MS = 1_500;
// Modèles à roter — free tier limits :
// gemini-2.5-flash : 10 RPM, 250K TPM, 250 RPD
// gemini-2.5-flash-lite : 15 RPM, 250K TPM, 1000 RPD
// gemini-2.0-flash : 15 RPM, 1M TPM, 200 RPD
// gemini-2.0-flash-lite : 30 RPM, 1M TPM, 200 RPD
// Total : ~70 RPM cumulé en théorie (limité par les TPM et RPD aussi)
const MODEL_ROTATION = [
{ name: "gemini-2.5-flash", inputPrice: 0.075 / 1_000_000, outputPrice: 0.3 / 1_000_000 },
{ name: "gemini-2.5-flash-lite", inputPrice: 0.1 / 1_000_000, outputPrice: 0.4 / 1_000_000 },
{ name: "gemini-2.0-flash", inputPrice: 0.1 / 1_000_000, outputPrice: 0.4 / 1_000_000 },
{ name: "gemini-2.0-flash-lite", inputPrice: 0.075 / 1_000_000, outputPrice: 0.3 / 1_000_000 },
] as const;
// CLI : --project <id> Défaut "wari" pour compat.
const argv = process.argv.slice(2);
const projectFlagIdx = argv.indexOf("--project");
const PROJECT_ID = projectFlagIdx >= 0 ? argv[projectFlagIdx + 1] : "wari";
if (!PROJECTS[PROJECT_ID]) {
console.error(`[annotate] Projet inconnu : "${PROJECT_ID}". Choix : ${Object.keys(PROJECTS).join(", ")}`);
process.exit(1);
}
const PROJECT = getProject(PROJECT_ID);
const SOURCE_ROOTS: { app: string; mobile?: string } = PROJECT.roots;
const DATA_DIR = join(process.cwd(), "data");
const GRAPH_FILE = join(DATA_DIR, graphFileName(PROJECT));
const ANNOTATIONS_FILE = join(DATA_DIR, annotationsFileName(PROJECT));
const COST_LOG_FILE = join(DATA_DIR, "annotation-cost.log");
console.log(`[annotate] Projet : ${PROJECT.name} (${PROJECT_ID})`);
console.log(`[annotate] Graph in : ${GRAPH_FILE}`);
console.log(`[annotate] Annotations out : ${ANNOTATIONS_FILE}`);
const SYSTEM_PROMPT = `Tu es un assistant qui annote des fichiers de code source en français pour un développeur.
Pour chaque fichier que tu reçois, écris une annotation de 2 à 4 phrases qui répond à :
- Que fait ce fichier ? (rôle dans le projet)
- Qu'expose-t-il ? (fonctions, composants, hooks principaux)
- Comment s'utilise-t-il ? (où il est appelé / branché)
Sois précis et concret. Pas de blabla générique du style "Ce fichier contient du code TypeScript".
Ne réécris pas le code, ne le commente pas ligne par ligne.
Format de sortie : du texte brut français, pas de markdown, pas de titre.`;
// --- Types ---
type Kind = "model" | "route" | "hook" | "component" | "function" | "file";
interface GraphNode {
id: string;
file: string;
root: "app" | "mobile";
kind: Kind;
exports: Array<{ name: string; type: "default" | "named" }>;
}
interface WariGraph {
nodes: GraphNode[];
edges: unknown[];
}
interface Annotation {
hash: string;
annotation: string;
generatedAt: string;
model: string;
inputTokens: number;
outputTokens: number;
costUsd: number;
costEur: number;
}
type AnnotationsFile = Record<string, Annotation>;
// --- Model rotation state ---
const cooldowns = new Map<string, number>(); // model name → timestamp (ms) until ready
function pickNextAvailableModel(): (typeof MODEL_ROTATION)[number] | null {
const now = Date.now();
for (const m of MODEL_ROTATION) {
const cd = cooldowns.get(m.name) ?? 0;
if (now >= cd) return m;
}
return null;
}
async function waitForAnyModelReady(): Promise<void> {
const now = Date.now();
const nextReadyAt = Math.min(...Array.from(cooldowns.values()));
const waitMs = Math.max(0, nextReadyAt - now);
if (waitMs > 0) {
const sec = Math.ceil(waitMs / 1000);
console.log(` ⏳ Tous les modèles en cooldown, attente ${sec}s...`);
await new Promise((r) => setTimeout(r, waitMs + 100));
}
}
// --- Helpers ---
function parseArgs(): { dryRun: boolean; limit: number } {
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const limitIdx = args.indexOf("--limit");
const limit =
limitIdx >= 0 && args[limitIdx + 1]
? Number.parseInt(args[limitIdx + 1], 10)
: Number.POSITIVE_INFINITY;
return { dryRun, limit };
}
function md5(content: string): string {
return createHash("md5").update(content).digest("hex");
}
function truncate(content: string, maxLines: number): string {
const lines = content.split("\n");
if (lines.length <= maxLines) return content;
return lines.slice(0, maxLines).join("\n") + `\n\n// ... (${lines.length - maxLines} lignes tronquées)`;
}
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
function toEur(usd: number): number {
return usd * EUR_USD_RATE;
}
function languageHint(filePath: string): string {
return filePath.endsWith(".tsx") ? "tsx" : "ts";
}
function buildUserPrompt(node: GraphNode, content: string): string {
const exportsStr = node.exports.length > 0
? node.exports.map((e) => `${e.type}:${e.name}`).join(", ")
: "(aucun)";
return `Fichier : \`${node.id}\` (kind=${node.kind})
Exports : ${exportsStr}
\`\`\`${languageHint(node.file)}
${truncate(content, MAX_LINES_INPUT)}/**
* Story 1.3 — Annotation Gemini API avec ROTATION DE MODÈLES
*
* Stratégie : 4 modèles Gemini en round-robin. Quand un hit son rate limit,
* mark en cooldown 60s et passe au suivant. Tous en cooldown → wait shortest.
*
* Avantage : ~4x le débit du free tier (chaque modèle a son propre quota).
*
* Usage :
* npm run annotate -- --dry-run # estime le coût
* npm run annotate -- --limit 10 # test sur 10 fichiers
* npm run annotate # annote tout
*/
import { GoogleGenAI } from "@google/genai";
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
appendFileSync,
} from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { performance } from "node:perf_hooks";
import { getProject, graphFileName, annotationsFileName, PROJECTS } from "../lib/projects";
// --- Load .env.local ---
function loadEnvLocal(): void {
const envPath = join(process.cwd(), ".env.local");
if (!existsSync(envPath)) return;
const content = readFileSync(envPath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, "");
if (!process.env[key]) process.env[key] = value;
}
}
// --- Configuration ---
const MAX_LINES_INPUT = 100;
const SAVE_INTERVAL = 10;
const EUR_USD_RATE = 0.92;
// Cooldown quand un modèle hit rate limit (60s suffisent pour reset le quota minute)
const COOLDOWN_AFTER_429_MS = 60_000;
// Petit délai entre requêtes pour ne pas spam
const DELAY_BETWEEN_REQUESTS_MS = 1_500;
// Modèles à roter — free tier limits :
// gemini-2.5-flash : 10 RPM, 250K TPM, 250 RPD
// gemini-2.5-flash-lite : 15 RPM, 250K TPM, 1000 RPD
// gemini-2.0-flash : 15 RPM, 1M TPM, 200 RPD
// gemini-2.0-flash-lite : 30 RPM, 1M TPM, 200 RPD
// Total : ~70 RPM cumulé en théorie (limité par les TPM et RPD aussi)
const MODEL_ROTATION = [
{ name: "gemini-2.5-flash", inputPrice: 0.075 / 1_000_000, outputPrice: 0.3 / 1_000_000 },
{ name: "gemini-2.5-flash-lite", inputPrice: 0.1 / 1_000_000, outputPrice: 0.4 / 1_000_000 },
{ name: "gemini-2.0-flash", inputPrice: 0.1 / 1_000_000, outputPrice: 0.4 / 1_000_000 },
{ name: "gemini-2.0-flash-lite", inputPrice: 0.075 / 1_000_000, outputPrice: 0.3 / 1_000_000 },
] as const;
// CLI : --project <id> Défaut "wari" pour compat.
const argv = process.argv.slice(2);
const projectFlagIdx = argv.indexOf("--project");
const PROJECT_ID = projectFlagIdx >= 0 ? argv[projectFlagIdx + 1] : "wari";
if (!PROJECTS[PROJECT_ID]) {
console.error(`[annotate] Projet inconnu : "${PROJECT_ID}". Choix : ${Object.keys(PROJECTS).join(", ")}`);
process.exit(1);
}
const PROJECT = getProject(PROJECT_ID);
const SOURCE_ROOTS: { app: string; mobile?: string } = PROJECT.roots;
const DATA_DIR = join(process.cwd(), "data");
const GRAPH_FILE = join(DATA_DIR, graphFileName(PROJECT));
const ANNOTATIONS_FILE = join(DATA_DIR, annotationsFileName(PROJECT));
const COST_LOG_FILE = join(DATA_DIR, "annotation-cost.log");
console.log(`[annotate] Projet : ${PROJECT.name} (${PROJECT_ID})`);
console.log(`[annotate] Graph in : ${GRAPH_FILE}`);
console.log(`[annotate] Annotations out : ${ANNOTATIONS_FILE}`);
const SYSTEM_PROMPT = `Tu es un assistant qui annote des fichiers de code source en français pour un développeur.
Pour chaque fichier que tu reçois, écris une annotation de 2 à 4 phrases qui répond à :
- Que fait ce fichier ? (rôle dans le projet)
- Qu'expose-t-il ? (fonctions, composants, hooks principaux)
- Comment s'utilise-t-il ? (où il est appelé / branché)
Sois précis et concret. Pas de blabla générique du style "Ce fichier contient du code TypeScript".
Ne réécris pas le code, ne le commente pas ligne par ligne.
Format de sortie : du texte brut français, pas de markdown, pas de titre.`;
// --- Types ---
type Kind = "model" | "route" | "hook" | "component" | "function" | "file";
interface GraphNode {
id: string;
file: string;
root: "app" | "mobile";
kind: Kind;
exports: Array<{ name: string; type: "default" | "named" }>;
}
interface WariGraph {
nodes: GraphNode[];
edges: unknown[];
}
interface Annotation {
hash: string;
annotation: string;
generatedAt: string;
model: string;
inputTokens: number;
outputTokens: number;
costUsd: number;
costEur: number;
}
type AnnotationsFile = Record<string, Annotation>;
// --- Model rotation state ---
const cooldowns = new Map<string, number>(); // model name → timestamp (ms) until ready
function pickNextAvailableModel(): (typeof MODEL_ROTATION)[number] | null {
const now = Date.now();
for (const m of MODEL_ROTATION) {
const cd = cooldowns.get(m.name) ?? 0;
if (now >= cd) return m;
}
return null;
}
async function waitForAnyModelReady(): Promise<void> {
const now = Date.now();
const nextReadyAt = Math.min(...Array.from(cooldowns.values()));
const waitMs = Math.max(0, nextReadyAt - now);
if (waitMs > 0) {
const sec = Math.ceil(waitMs / 1000);
console.log(` ⏳ Tous les modèles en cooldown, attente ${sec}s...`);
await new Promise((r) => setTimeout(r, waitMs + 100));
}
}
// --- Helpers ---
function parseArgs(): { dryRun: boolean; limit: number } {
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const limitIdx = args.indexOf("--limit");
const limit =
limitIdx >= 0 && args[limitIdx + 1]
? Number.parseInt(args[limitIdx + 1], 10)
: Number.POSITIVE_INFINITY;
return { dryRun, limit };
}
function md5(content: string): string {
return createHash("md5").update(content).digest("hex");
}
function truncate(content: string, maxLines: number): string {
const lines = content.split("\n");
if (lines.length <= maxLines) return content;
return lines.slice(0, maxLines).join("\n") + `\n\n// ... (${lines.length - maxLines} lignes tronquées)`;
}
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
function toEur(usd: number): number {
return usd * EUR_USD_RATE;
}
function languageHint(filePath: string): string {
return filePath.endsWith(".tsx") ? "tsx" : "ts";
}
function buildUserPrompt(node: GraphNode, content: string): string {
const exportsStr = node.exports.length > 0
? node.exports.map((e) => `${e.type}:${e.name}`).join(", ")
: "(aucun)";
return `Fichier : \`${node.id}\` (kind=${node.kind})
Exports : ${exportsStr}
\`\`\`${languageHint(node.file)}
${truncate(content, MAX_LINES_INPUT)}