Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[submodule "wren-engine"]
path = wren-engine
url = git@github.com:Canner/wren-engine.git
url = https://github.com/Canner/wren-engine.git
31 changes: 31 additions & 0 deletions README_START_HERE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# WrenAI Starter: OpenAI Text-to-SQL + Local Insights (Ollama)

## Steps
1) Fork & clone your WrenAI repo; checkout a new branch:
```bash
git submodule update --init --recursive
git checkout -b feat/local-ollama-insights
```
2) Copy `.env.example` to `.env` and fill in your `OPENAI_API_KEY`.
3) Ensure Ollama is running:
```bash
ollama pull llama3.1:8b
ollama serve
```
4) Drop the `wren-ai-service/src/**` files into your repo (create folders if missing).
5) Apply the two patch hints (`*.PATCH.txt`) to the existing files in your codebase.
- `wren-ai-service/src/providers/llm/index.ts.PATCH.txt`
- `wren-ai-service/src/routes/index.ts.PATCH.txt`
6) Build & run:
```bash
docker compose build
docker compose up -d
```
7) Test:
```bash
curl -X POST http://localhost:7000/insights -H 'Content-Type: application/json' -d '{"sql":"select * from sales limit 5;","columns":["id","amount"],"rows":[{"id":1,"amount":100}]}'
```

## Notes
- Toggle insights: set `INSIGHTS_ENABLED=false` in `.env`.
- Use a smaller local model if needed: `INSIGHTS_OLLAMA_MODEL=llama3.2:3b-instruct`.
33 changes: 33 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
services:
java-engine:
image: ghcr.io/canner/wren-engine:latest
container_name: wren-java-engine
ports: ["8080:8080"]
environment:
MAX_HEAP_SIZE: 819m
MIN_HEAP_SIZE: 819m

ibis-server:
image: ghcr.io/canner/wren-engine-ibis:latest
container_name: wren-ibis
ports: ["8000:8000"]
environment:
WREN_ENGINE_ENDPOINT: http://java-engine:8080
depends_on: [java-engine]

wren-ai-service:
build: ./wren-ai-service
container_name: wren-ai-service
ports: ["7001:7000"]
env_file: [.env]
depends_on: [ibis-server]
extra_hosts:
- "host.docker.internal:host-gateway"

wren-ui:
build: ./wren-ui
container_name: wren-ui
ports: ["3000:3000"]
environment:
WREN_API_BASE: http://wren-ai-service:7000
depends_on: [wren-ai-service]
7 changes: 2 additions & 5 deletions wren-ai-service/.dockerignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,2 @@
*
!src
!entrypoint.sh
!pyproject.toml
src/eval
node_modules
dist
9 changes: 9 additions & 0 deletions wren-ai-service/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json tsconfig.json ./
RUN npm install
COPY src ./src
ENV PORT=7000
EXPOSE 7000
RUN npm run build
CMD ["npm","run","start"]
38 changes: 8 additions & 30 deletions wren-ai-service/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,31 +1,9 @@
# reference: https://medium.com/@albertazzir/blazing-fast-python-docker-builds-with-poetry-a78a66f5aed0
FROM python:3.12.0-bookworm as builder

RUN pip install poetry==1.8.3

ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache

FROM node:20-alpine
WORKDIR /app

COPY pyproject.toml ./

RUN poetry install --without dev,eval,test --no-root && rm -rf $POETRY_CACHE_DIR

FROM python:3.12.0-slim-bookworm as runtime

RUN apt-get update && apt install -y netcat-traditional

ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"

COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}

COPY src src
COPY entrypoint.sh /app/entrypoint.sh
COPY pyproject.toml pyproject.toml
RUN chmod +x /app/entrypoint.sh

ENTRYPOINT [ "/app/entrypoint.sh" ]
COPY package.json tsconfig.json ./
RUN npm ci
COPY src ./src
ENV PORT=7000
EXPOSE 7000
RUN npm run build
CMD ["npm","run","start"]
21 changes: 21 additions & 0 deletions wren-ai-service/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import express from 'express';
import cors from 'cors';
import registerRoutes from './routes/index.js';

const app = express();
app.use(express.json());

// allow local Next.js on port 3000
app.use(cors({
origin: ['http://localhost:3000'],
credentials: false
}));

app.get('/health', (_req, res) => res.json({ ok: true }));

registerRoutes(app);

const PORT = process.env.PORT || 7000;
app.listen(PORT, () => {
console.log(`[wren-ai-service] listening on http://0.0.0.0:${PORT}`);
});
21 changes: 21 additions & 0 deletions wren-ai-service/src/providers/llm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getOpenAiClient } from './openai.js';
import { OllamaClient } from './ollama.js';

export type LlmKind = 'openai' | 'ollama';

/** LLM client for INSIGHTS ONLY */
export function getInsightsLlm() {
const kind = (process.env.INSIGHTS_PROVIDER ?? 'openai').toLowerCase() as LlmKind;

if (kind === 'ollama') {
return new OllamaClient(
process.env.INSIGHTS_OLLAMA_BASE ?? 'http://host.docker.internal:11434',
process.env.INSIGHTS_OLLAMA_MODEL ?? 'llama3.1:8b'
);
}

return getOpenAiClient({
apiKey: process.env.OPENAI_API_KEY || '',
model: process.env.INSIGHTS_MODEL || 'gpt-4o-mini'
});
}
26 changes: 26 additions & 0 deletions wren-ai-service/src/providers/llm/ollama.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import fetch from 'node-fetch';

type OllamaGenerateResp = {
response?: string;
// stream mode has different fields; we don't use it here
};

export class OllamaClient {
constructor(private baseUrl: string, private model: string) {}

async generate(prompt: string): Promise<string> {
const resp = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.model, prompt, stream: false })
});

if (!resp.ok) {
const text = await resp.text();
throw new Error(`Ollama error: ${resp.status} ${text}`);
}

const data = (await resp.json()) as OllamaGenerateResp;
return (data.response ?? '').trim();
}
}
42 changes: 42 additions & 0 deletions wren-ai-service/src/providers/llm/openai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import fetch from 'node-fetch';

interface OpenAIOpts {
apiKey: string;
model: string;
}

type ChatMsg = { role: 'system' | 'user' | 'assistant'; content: string };

type OpenAIResp = {
choices?: Array<{ message?: ChatMsg }>;
};

export function getOpenAiClient(opts: OpenAIOpts) {
return {
async generate(prompt: string): Promise<string> {
const resp = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${opts.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: opts.model,
messages: [
{ role: 'system', content: 'You are a helpful data analyst.' },
{ role: 'user', content: prompt }
],
temperature: 0.2
})
});

if (!resp.ok) {
const text = await resp.text();
throw new Error(`OpenAI error: ${resp.status} ${text}`);
}

const data = (await resp.json()) as OpenAIResp;
return (data.choices?.[0]?.message?.content ?? '').trim();
}
};
}
9 changes: 9 additions & 0 deletions wren-ai-service/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Express } from 'express';
import insightsRouter from './insights.js';

export default function registerRoutes(app: Express) {
const insightsEnabled = String(process.env.INSIGHTS_ENABLED ?? 'true').toLowerCase() === 'true';
if (insightsEnabled) {
app.use('/insights', insightsRouter);
}
}
16 changes: 16 additions & 0 deletions wren-ai-service/src/routes/insights.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Router } from 'express';
import { createInsights } from '../services/insights.js';

const router = Router();

router.post('/', async (req, res) => {
try {
const { sql, rows, columns } = req.body || {};
const result = await createInsights({ sql, rows, columns });
return res.json(result);
} catch (err: any) {
return res.status(500).json({ error: String(err?.message ?? err) });
}
});

export default router;
32 changes: 32 additions & 0 deletions wren-ai-service/src/services/insights.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { getInsightsLlm } from '../providers/llm/index.js';

export interface InsightInput {
sql: string;
rows: any[];
columns: string[];
}

export async function createInsights({ sql, rows, columns }: InsightInput) {
const enabled = String(process.env.INSIGHTS_ENABLED ?? 'true').toLowerCase() === 'true';
if (!enabled) return { insights: [], disabled: true };

const llm = getInsightsLlm();

// keep prompt concise for speed
const sample = (rows ?? []).slice(0, 10); // was 50

const prompt = `You are a BI analyst. Summarize key takeaways from this SQL result.
Return 3–5 concise, numerically-grounded bullet points.

SQL:
${sql}

Columns: ${columns?.join(', ')}

Sample rows (first 10):
${JSON.stringify(sample, null, 2)}
`;

const text = await llm.generate(prompt);
return { insights: text.trim() };
}
Binary file added wren-ui/.wren/dev.db
Binary file not shown.
Binary file added wren-ui/.wren/dev.db-shm
Binary file not shown.
Empty file added wren-ui/.wren/dev.db-wal
Empty file.
39 changes: 19 additions & 20 deletions wren-ui/src/apollo/server/repositories/baseRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,32 +76,31 @@ export class BaseRepository<T> implements IBasicRepository<T> {
: null;
}

public async findAllBy(filter: Partial<T>, queryOptions?: IQueryOptions) {
const executer = queryOptions?.tx ? queryOptions.tx : this.knex;
// format filter keys to snake_case

const query = executer(this.tableName).where(
this.transformToDBData(filter),
);
if (queryOptions?.order) {
query.orderBy(queryOptions.order);
public async findAll(): Promise<T[]> {
try {
return await this.knex.select('*').from(this.tableName);
} catch (err: any) {
const msg = String(err?.message ?? '');
if ((err?.code === 'SQLITE_ERROR' || msg.includes('SQLITE_ERROR')) && msg.includes('no such table')) {
return [];
}
throw err;
}
const result = await query;
return result.map(this.transformFromDBData);
}

public async findAll(queryOptions?: IQueryOptions) {
const executer = queryOptions?.tx ? queryOptions.tx : this.knex;
const query = executer(this.tableName);
if (queryOptions?.order) {
query.orderBy(queryOptions.order);
try {
return await this.knex.select('*').from(this.tableName);
} catch (err: any) {
// Fast-unblock: if the table isn't created yet, act as empty
const msg = String(err?.message ?? '');
if ((err?.code === 'SQLITE_ERROR' || msg.includes('SQLITE_ERROR')) && msg.includes('no such table')) {
return [];
}
throw err;
}
if (queryOptions?.limit) {
query.limit(queryOptions.limit);
}
const result = await query;
return result.map(this.transformFromDBData);
}


public async createOne(data: Partial<T>, queryOptions?: IQueryOptions) {
const executer = queryOptions?.tx ? queryOptions.tx : this.knex;
Expand Down
12 changes: 12 additions & 0 deletions wren-ui/src/apollo/server/services/askingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,18 @@ export class AskingService implements IAskingService {
public async initialize() {
// list thread responses from database
// filter status not finalized and put them into background tracker
try {
// existing logic (e.g., loading thread responses, caches, etc.)
await this.threadResponseRepository.findAll(); // or whatever it calls first
} catch (err: any) {
const msg = String(err?.message ?? '');
if ((err?.code === 'SQLITE_ERROR' || msg.includes('SQLITE_ERROR')) && msg.includes('no such table')) {
// ignore on cold start; background trackers will run with empty state
console.warn('[AskingService] Missing tables on cold start; continuing.');
} else {
throw err;
}
}
const threadResponses = await this.threadResponseRepository.findAll();
const unfininshedBreakdownThreadResponses = threadResponses.filter(
(threadResponse) =>
Expand Down