Initial public push: docs cosmos v4 + AI module + framework groundwork
This is the snapshot the production landing site (nibiru-framework.com) is deployed from. Brings together the recent splash + docs migration to the v4 "Cosmos" design system, the new in-framework AI module, and the framework groundwork that backs the framework-reference extraction. What lands: - docs/: Astro + Starlight site with the v4 dark cosmic palette, GalaxyHero canvas constellation, Mission Control chat (wired to /api/oracle → api.neuronetz.ai via providers.mjs Ollama), 5-panel MMVC stage (Model · AI · Module · Controller · View), translated EN/DE/JA/ES/FR content, PWA + sitemap + llms.txt + Umami analytics. - docs/design-system/: canonical mockup bundle (source/index-v2.html for splash, source/docs-system.html + preview/ for docs, SPEC.md, tokens). - docs/scripts/extraction/framework-reference-v2.md: deep framework reference (~1.6k lines, file:line citations, every public factory and idiom — basis for the LoRA training corpus. - application/module/ai/: AI module with chat / embed / RAG / agent plugins, plus pdoQuery / httpGet / fileRead tools and Modelfile + smoke-test in training/. - application/module/users/: user / ACL / form-factory traits used as the reference plugin pattern for the framework docs. - application/settings/config/database/: schema + seed migrations including the AI module tables (200–203). - Form factory + autogenerator changes the framework-reference-v2 covers. Production secrets stay out: docs/.env, settings.production.ini and ai.production.ini are all gitignored (.example files are in tree). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
207
docs/src/pages/api/oracle.ts
Normal file
207
docs/src/pages/api/oracle.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// Provider abstraction is shared with the build script.
|
||||
// @ts-expect-error - .mjs import has no types
|
||||
import { chat, embed, llmConfig, embedConfig } from '../../../scripts/lib/providers.mjs';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const TOP_K = Number(process.env.ORACLE_TOP_K ?? 6);
|
||||
const MAX_TOKENS = Number(process.env.ORACLE_MAX_TOKENS ?? 800);
|
||||
|
||||
type Chunk = {
|
||||
id: string;
|
||||
url: string;
|
||||
pageTitle: string;
|
||||
sectionTitle: string;
|
||||
language: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type Index = {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
dim: number;
|
||||
builtAt: string | null;
|
||||
chunks: Chunk[];
|
||||
embeddings: string[];
|
||||
};
|
||||
|
||||
let cachedIndex: { chunks: Chunk[]; vectors: Float32Array[] } | null = null;
|
||||
|
||||
function loadIndex() {
|
||||
if (cachedIndex) return cachedIndex;
|
||||
const indexPath = path.resolve(process.cwd(), 'public/oracle-index.json');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
cachedIndex = { chunks: [], vectors: [] };
|
||||
return cachedIndex;
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(indexPath, 'utf8')) as Index;
|
||||
const vectors = raw.embeddings.map((b64) => {
|
||||
const buf = Buffer.from(b64, 'base64');
|
||||
return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
||||
});
|
||||
cachedIndex = { chunks: raw.chunks, vectors };
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
function cosine(a: Float32Array, b: Float32Array): number {
|
||||
let dot = 0,
|
||||
na = 0,
|
||||
nb = 0;
|
||||
const len = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
dot += a[i] * b[i];
|
||||
na += a[i] * a[i];
|
||||
nb += b[i] * b[i];
|
||||
}
|
||||
const denom = Math.sqrt(na) * Math.sqrt(nb);
|
||||
return denom === 0 ? 0 : dot / denom;
|
||||
}
|
||||
|
||||
function detectLanguage(text: string): string {
|
||||
if (/[-ヿ㐀-䶿一-鿿]/.test(text)) return 'ja';
|
||||
if (/\b(qué|cómo|dónde|cuándo|porqué|hola|gracias)\b/i.test(text)) return 'es';
|
||||
if (/\b(comment|qu'est-ce|pourquoi|où|quand|bonjour)\b/i.test(text)) return 'fr';
|
||||
if (/\b(wie|was|wo|wann|warum|danke|hallo)\b/i.test(text)) return 'de';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPTS: Record<string, string> = {
|
||||
en: 'You are the Nibiru Oracle, a helpful AI assistant for the Nibiru PHP framework. Use the provided documentation excerpts to answer the user. Be concise, give concrete code examples, and cite sources by their reference number like [1], [2]. If the docs do not cover the question, say so plainly.',
|
||||
de: 'Du bist das Nibiru-Orakel, ein hilfreicher KI-Assistent für das Nibiru-PHP-Framework. Nutze die bereitgestellten Dokumentationsauszüge zur Antwort. Sei prägnant, gib konkrete Code-Beispiele und zitiere Quellen mit ihrer Referenznummer wie [1], [2]. Wenn die Dokumentation die Frage nicht abdeckt, sag es klar.',
|
||||
ja: 'あなたは Nibiru Oracle です — Nibiru PHP フレームワークの親切な AI アシスタントです。提供されたドキュメント抜粋を使って回答してください。簡潔に、具体的なコード例を示し、参照番号 [1]、[2] のように引用してください。ドキュメントに該当がなければ、はっきりとそう述べてください。',
|
||||
es: 'Eres el Oráculo de Nibiru, un asistente de IA útil para el framework PHP Nibiru. Usa los fragmentos de documentación proporcionados para responder. Sé conciso, da ejemplos de código concretos y cita las fuentes por su número de referencia como [1], [2]. Si la documentación no cubre la pregunta, dilo claramente.',
|
||||
fr: "Tu es l'Oracle de Nibiru, un assistant IA utile pour le framework PHP Nibiru. Utilise les extraits de documentation fournis pour répondre. Sois concis, donne des exemples de code concrets et cite les sources par leur numéro de référence comme [1], [2]. Si la documentation ne couvre pas la question, dis-le clairement.",
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const lcfg = llmConfig();
|
||||
if (lcfg.provider === 'anthropic' && !lcfg.hasAnthropicKey) {
|
||||
return json({
|
||||
answer:
|
||||
'The Oracle is silent — `LLM_PROVIDER=anthropic` is set but `ANTHROPIC_API_KEY` is missing. Set the key, or switch back to `LLM_PROVIDER=ollama` (which is the default and uses your neuronetz.ai instance).',
|
||||
sources: [],
|
||||
});
|
||||
}
|
||||
|
||||
let body: { messages: Array<{ role: string; content: string }> };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response('Invalid JSON', { status: 400 });
|
||||
}
|
||||
|
||||
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
|
||||
const userText = (lastUser?.content || '').trim();
|
||||
if (!userText) return new Response('Empty question', { status: 400 });
|
||||
|
||||
const lang = detectLanguage(userText);
|
||||
const { chunks, vectors } = loadIndex();
|
||||
|
||||
// --- Retrieval (RAG) -------------------------------------------------------
|
||||
let context: Chunk[] = [];
|
||||
if (chunks.length > 0) {
|
||||
try {
|
||||
const [qVec] = await embed(userText);
|
||||
if (qVec) {
|
||||
const queryF32 = new Float32Array(qVec);
|
||||
const scored = vectors
|
||||
.map((v, i) => ({ i, score: cosine(queryF32, v) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const sameLang = scored.filter((s) => chunks[s.i].language === lang);
|
||||
const pool = sameLang.length >= TOP_K ? sameLang : scored;
|
||||
context = pool.slice(0, TOP_K).map((s) => chunks[s.i]);
|
||||
}
|
||||
} catch (err) {
|
||||
// Embedding failed at runtime — degrade gracefully to chat-only.
|
||||
console.warn(
|
||||
`[oracle] runtime embedding failed: ${(err as Error).message}; continuing without RAG.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const contextBlock = context
|
||||
.map((c, i) => `[${i + 1}] ${c.pageTitle} — ${c.sectionTitle}\n${c.content}`)
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
const system = SYSTEM_PROMPTS[lang] ?? SYSTEM_PROMPTS.en;
|
||||
const augmentedSystem = contextBlock
|
||||
? `${system}\n\nDocumentation excerpts:\n\n${contextBlock}`
|
||||
: system;
|
||||
|
||||
// --- Generation ------------------------------------------------------------
|
||||
try {
|
||||
const { text, model, provider } = await chat({
|
||||
system: augmentedSystem,
|
||||
messages,
|
||||
maxTokens: MAX_TOKENS,
|
||||
});
|
||||
|
||||
return json({
|
||||
answer: text,
|
||||
sources: context.map((c) => ({ title: c.sectionTitle, url: c.url })),
|
||||
language: lang,
|
||||
model,
|
||||
provider,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return json(
|
||||
{
|
||||
answer: `The Oracle could not answer: ${message}`,
|
||||
sources: [],
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Tiny GET handler so health-check / debugging shows config without secrets.
|
||||
export const GET: APIRoute = async () => {
|
||||
const llm = llmConfig();
|
||||
const emb = embedConfig();
|
||||
const indexPath = path.resolve(process.cwd(), 'public/oracle-index.json');
|
||||
const indexExists = fs.existsSync(indexPath);
|
||||
let chunkCount = 0;
|
||||
let indexProvider = null;
|
||||
let indexModel = null;
|
||||
if (indexExists) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(indexPath, 'utf8')) as Index;
|
||||
chunkCount = raw.chunks?.length ?? 0;
|
||||
indexProvider = raw.provider;
|
||||
indexModel = raw.model;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return json({
|
||||
status: 'ok',
|
||||
llm: {
|
||||
provider: llm.provider,
|
||||
ollamaUrl: llm.provider === 'ollama' ? llm.ollamaUrl : undefined,
|
||||
model: llm.provider === 'ollama' ? llm.ollamaChatModel : llm.anthropicModel,
|
||||
},
|
||||
embed: {
|
||||
provider: emb.provider,
|
||||
ollamaUrl: emb.provider === 'ollama' ? emb.ollamaUrl : undefined,
|
||||
model: emb.provider === 'ollama' ? emb.ollamaEmbedModel : emb.openaiEmbedModel,
|
||||
},
|
||||
index: {
|
||||
present: indexExists,
|
||||
chunks: chunkCount,
|
||||
provider: indexProvider,
|
||||
model: indexModel,
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user