Skip to content

Commit a736c35

Browse files
committed
Feat: Implement Chat boat for Qualityfolio
1 parent 0a56d13 commit a736c35

32 files changed

Lines changed: 1829 additions & 0 deletions

sqlpage/sqlpage.db

Whitespace-only changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
OPENAI_API_KEY=sk-your-openai-key
2+
GEMINI_API_KEY=AI-key
3+
GROQ_API_KEY=gsk_key
4+
ANTHROPIC_API_KEY=anthropic-key
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# QualityFolio Chat
2+
3+
A full-stack AI-powered chat application combining **SQLPage**, **LiteLLM**, and a **React-based Assistant UI**, designed to query a Resource Surveillance State Database (RSSD) using natural language.
4+
5+
## Overview
6+
7+
QualityFolio Chat allows users to ask natural language questions about quality data. It uses LiteLLM as an LLM proxy (supporting OpenAI-compatible models including local Ollama models), SQLPage to serve a web UI from SQL, and a React chat widget for the frontend.
8+
9+
---
10+
11+
## Requirements
12+
13+
### System
14+
15+
| Requirement | Version / Notes |
16+
|---|---|
17+
| **Node.js** | v18+ |
18+
| **Python** | 3.10+ |
19+
| **npm** | v9+ |
20+
| **SQLPage** | Latest (binary in PATH) |
21+
| **Spry CLI** | Installed and in PATH |
22+
| **Ollama** *(optional)* | Required if using local models (e.g. `oss-20b-32K:latest`) |
23+
24+
### Python Packages
25+
26+
Install via pip:
27+
28+
```bash
29+
pip install 'litellm[proxy]'
30+
```
31+
32+
Or if using a virtual environment (recommended):
33+
34+
```bash
35+
python -m venv litellm-venv
36+
source litellm-venv/bin/activate
37+
pip install 'litellm[proxy]'
38+
```
39+
40+
### Node Packages (Frontend)
41+
42+
Installed automatically via `npm install` inside `assistant-ui-chat/`.
43+
44+
---
45+
46+
## Project Structure
47+
48+
```
49+
qualityfolio-chat/
50+
├── assistant-ui-chat/ # React frontend (Assistant UI)
51+
│ └── ...
52+
├── sqlpage/
53+
│ └── sqlpage.js # SQLPage configuration
54+
├── dev-src.auto/ # Auto-generated SQLPage sources
55+
├── litellm_config.yaml # LiteLLM model & routing config
56+
├── qualityfolio.md # Spry source definition
57+
├── chat-widget-react.js # Compiled React chat widget
58+
├── chat-widget-react-index.css
59+
├── .env # Environment variables (API keys, etc.)
60+
├── .env.example # Example environment file
61+
└── poly.sql # SQL definitions
62+
```
63+
64+
---
65+
66+
## Setup & Running
67+
68+
Follow these steps **in order**. Each step should be run in a separate terminal if running concurrently.
69+
70+
### Step 1 — Run Spry Commands
71+
72+
Generate the SQLPage sources from the markdown definition:
73+
74+
```bash
75+
spry rb run qualityfolio.md
76+
spry sp spc --fs dev-src.auto --destroy-first --conf sqlpage/sqlpage.js --md qualityfolio.md
77+
```
78+
79+
### Step 2 — Start SQLPage
80+
81+
Serve the SQLPage web interface:
82+
83+
```bash
84+
sqlpage
85+
```
86+
87+
SQLPage will serve from the `dev-src.auto/` directory. Visit `http://localhost:9227` (or the configured port) in your browser.
88+
89+
### Step 4 — Start LiteLLM
90+
91+
Load environment variables and start the LiteLLM proxy:
92+
93+
```bash
94+
source .env && litellm --config litellm_config.yaml
95+
```
96+
97+
> **Note:** Ensure `.env` contains all required API keys or model endpoint URLs. See `.env.example` for reference.
98+
99+
### Step 5 — Start the Frontend
100+
101+
Install dependencies and run the React dev server:
102+
103+
```bash
104+
cd assistant-ui-chat
105+
npm install
106+
npm run dev
107+
```
108+
109+
The frontend will be available at `http://localhost:3000` (or as configured).
110+
111+
---
112+
113+
## Environment Variables
114+
115+
Copy `.env.example` to `.env` and fill in your values:
116+
117+
```bash
118+
cp .env.example .env
119+
```
120+
121+
Key variables to configure:
122+
123+
| Variable | Description |
124+
|---|---|
125+
| `OPENAI_API_KEY` | OpenAI API key (if using OpenAI models) |
126+
| `OLLAMA_BASE_URL` | Ollama base URL (default: `http://localhost:11434`) |
127+
| `DATABASE_URL` | Path or connection string for the RSSD database |
128+
129+
---
130+
131+
## Troubleshooting
132+
133+
### `UnboundLocalError: cannot access local variable 'completion_output'`
134+
135+
This is a known bug in some versions of LiteLLM when using tool-calling models with streaming. It is non-blocking but can be resolved by upgrading LiteLLM:
136+
137+
```bash
138+
pip install --upgrade 'litellm[proxy]'
139+
```
140+
141+
### LiteLLM: "upstream model provider is currently experiencing high demand"
142+
143+
This is a transient error from the model provider. Wait a moment and retry, or switch to a different model in `litellm_config.yaml`.
144+
145+
### SQLPage not serving updated files
146+
147+
Re-run Steps 1 and 2 to regenerate `dev-src.auto/`, then restart SQLPage.
148+
149+
### Frontend not connecting to chat API
150+
151+
Ensure LiteLLM is running (Step 4) and that the API endpoint in `assistant-ui-chat` matches the LiteLLM proxy address (typically `http://localhost:4000`).
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## LiteLLM gateway
2+
3+
LITELLM_BASE_URL=http://localhost:4000
4+
LITELLM_API_KEY=sk-key
5+
RSSD_PATH=../resource-surveillance.sqlite.db
6+
7+
# Default model alias — must match a model_name in litellm_config.yaml
8+
9+
AI_MODEL=chat
10+
NEXT_PUBLIC_SQLPAGE_BASE_URL=http://localhost:8080/
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import {
2+
streamText,
3+
convertToModelMessages,
4+
stepCountIs,
5+
createUIMessageStream,
6+
} from "ai";
7+
import { createMCPClient } from "@ai-sdk/mcp";
8+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
9+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
10+
11+
export const maxDuration = 60;
12+
13+
let cachedTableNames: string[] | null = null;
14+
15+
async function getKnownTables(mcpTools: Record<string, { execute?: Function }>): Promise<string[]> {
16+
if (cachedTableNames) return cachedTableNames;
17+
18+
const querySqlTool = mcpTools.query_sql;
19+
if (!querySqlTool?.execute) return [];
20+
21+
try {
22+
const result = await querySqlTool.execute(
23+
{
24+
sql: "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name",
25+
limit: 200,
26+
},
27+
{
28+
toolCallId: "known-tables-cache",
29+
messages: [],
30+
},
31+
);
32+
33+
const textContent = (
34+
result?.content as Array<{ type?: string; text?: string }> | undefined
35+
)?.find((entry) => entry.type === "text")?.text;
36+
37+
if (!textContent) return [];
38+
39+
const parsed = JSON.parse(textContent) as {
40+
rows?: Array<{ name?: string }>;
41+
};
42+
43+
cachedTableNames =
44+
parsed.rows
45+
?.map((row) => row.name)
46+
.filter((name): name is string => Boolean(name)) ?? [];
47+
48+
return cachedTableNames;
49+
} catch {
50+
return [];
51+
}
52+
}
53+
54+
const open_model = createOpenAICompatible({
55+
baseURL: process.env.LITELLM_BASE_URL!,
56+
name: process.env.AI_MODEL!,
57+
apiKey: process.env.LITELLM_API_KEY!,
58+
});
59+
60+
const mcpClient = await createMCPClient({
61+
transport: new StdioClientTransport({
62+
command: "surveilr",
63+
args: ["mcp", "server", "-d", process.env.RSSD_PATH!],
64+
}),
65+
});
66+
67+
const tools = await mcpClient.tools();
68+
69+
console.log(
70+
"---------------------------------TOOLS---------------------------------------",
71+
);
72+
console.log(tools);
73+
console.log(
74+
"-----------------------------------------------------------------------------",
75+
);
76+
77+
export async function POST(req: Request) {
78+
try {
79+
const { messages, model } = await req.json();
80+
81+
if (!messages || !Array.isArray(messages)) {
82+
return new Response("Invalid messages", { status: 400 });
83+
}
84+
85+
const knownTables = await getKnownTables(tools as Record<string, { execute?: Function }>);
86+
87+
const tableHint = knownTables.length
88+
? `\n\nAvailable tables and views in the RSSD (use exact names, do not guess pluralizations or variations):\n${knownTables.join(", ")}.`
89+
: "";
90+
91+
const systemPrompt = `You are an AI assistant connected to a surveilr Resource Surveillance State Database (RSSD) via an MCP server. Your primary capability is answering questions by generating and executing SQL queries against the RSSD — a read-only SQLite database.
92+
93+
Use a "Progressive Discovery" strategy: start with lightweight tools and escalate only when needed. You have a maximum of 15 tool calls per response — use them efficiently.
94+
95+
Core Constraints:
96+
- Read-only: Only SELECT statements are permitted. Never attempt INSERT, UPDATE, DELETE, DROP, or any DDL.
97+
- Row limits: Queries return 10 rows by default, max 50 rows. Request more explicitly only when truly necessary.
98+
- Text truncation: All text fields are truncated at 200 characters. If a value ends with "... (N chars total)", the full value is longer than displayed.
99+
- Step budget: You have at most 15 tool calls per response. Prefer the minimum number of calls needed.
100+
101+
Available MCP Tools:
102+
1. Schema Discovery (use these FIRST):
103+
- list_tables(): ~50-100 tokens. Use at the start of a new conversation to see what tables exist.
104+
- get_table_columns(table_name): ~50-200 tokens. Use once you know which tables are relevant.
105+
- get_table_metadata(table_name): Detailed column definitions for a specific table.
106+
- get_schema_compact(): ~2k-5k tokens. Use when you need a broad overview of the full database structure.
107+
- get_schema(): ~25k-80k tokens. Use only when full metadata and row counts are explicitly required.
108+
109+
2. Data Sampling:
110+
- get_table_sample(table_name): Returns first 3 rows from a table; text fields truncated to 200 chars.
111+
- get_table_stats(table_name): Get row count and basic stats for a table.
112+
113+
3. Query Execution:
114+
- query_sql(sql, limit?): Execute a SELECT query. Default 10 rows, max 50 rows.
115+
116+
4. Ontology Tools:
117+
- query_ontology(concept): Look up a concept in the RSSD ontology.
118+
- explore_concept(class_name): Explore relationships connected to an ontology class.
119+
- list_ontology(): List available ontology classes.
120+
121+
Optimal Text-to-SQL Workflow:
122+
1. MAP: Call \`list_tables()\` first (only if you don't already know the schema from this conversation) to identify candidate tables.
123+
2. DRILL: Call \`get_table_columns(table_name)\` for only 1-2 tables that look relevant to the user's question.
124+
3. INSPECT: Call \`get_table_sample(table_name)\` to see example values (text is truncated for efficiency).
125+
4. QUERY: Use \`query_sql\` with narrow SELECT statements and specific WHERE clauses.
126+
127+
- If a user asks for "passed tests," look for QualityFolio (QF) or evidence tables in the schema.
128+
- Always prefer small, targeted tool calls over broad discovery.
129+
- When a query returns no results, try relaxing WHERE filters or checking column values via \`get_table_sample\` before concluding the data doesn't exist.
130+
131+
Analysis & Recommendations:
132+
- After retrieving data, ALWAYS provide analysis and actionable recommendations when the user asks for insights, improvements, or recommendations.
133+
- When asked about improving test pass rates: query relevant test result data, identify failing patterns, and suggest concrete improvement steps based on the data found.
134+
- When asked about trends: compare data across time, test suites, or categories and highlight notable patterns.
135+
- When asked for recommendations: base them on actual data retrieved from the RSSD and supplement with testing best practices.
136+
- Never refuse to provide recommendations simply because you are a database tool — you are an AI analyst that uses the database as your data source.
137+
- If the data is insufficient to give a full recommendation, state what data was found and what additional data would help.
138+
139+
Behavioral Rules:
140+
1. Always start with list_tables() on the FIRST turn of a conversation. On subsequent turns, reuse schema already discovered — do not re-run list_tables() or get_table_columns() for already-inspected tables.
141+
2. Never call get_schema() unless the user explicitly asks for full schema metadata. It is expensive (25k-80k tokens).
142+
3. Chain tools efficiently: list_tables -> get_table_columns -> query_sql is the default happy path.
143+
4. Validate before querying: Confirm table and column names exist via discovery tools before writing SQL. Do not guess column names.
144+
5. Explain truncation: If a text result ends with "... (N chars total)", inform the user the value was truncated and offer to query with a targeted filter.
145+
6. Limit discipline: Default to limit=10. Only increase to max 50 if the user explicitly needs more data.
146+
7. SQL safety: Never generate or execute non-SELECT SQL. If the user asks to modify data, explain that the MCP server is read-only.
147+
8. Surface ontology when relevant: If the user's question involves concepts, classifications, or taxonomy, consider list_ontology() or query_ontology() before writing SQL.
148+
9. Empty results: If a query returns no rows, inform the user, suggest possible reasons (wrong filter value, different column name), and offer a follow-up query to verify.
149+
10. Silent execution: Never narrate tool calls, discovery steps, or intermediate findings in the response. Only output the final answer.
150+
151+
Anti-Patterns to Avoid:
152+
- Calling get_schema() on the first turn "just to be safe".
153+
- Guessing column names without calling get_table_columns() first.
154+
- Requesting limit=50 when the user only asked for a summary.
155+
- Re-running list_tables() or get_table_columns() for tables already inspected in this conversation.
156+
- Treating truncated text values as the full value.
157+
- Writing JOINs without first confirming the join key columns exist in both tables.${tableHint}`;
158+
159+
const result = streamText({
160+
model: open_model(process.env.AI_MODEL!),
161+
tools: tools,
162+
messages: await convertToModelMessages(messages),
163+
system: systemPrompt,
164+
stopWhen: stepCountIs(15),
165+
onStepFinish: async ({ toolResults }) => {
166+
if (toolResults.length) {
167+
console.log(JSON.stringify(toolResults, null, 2));
168+
}
169+
},
170+
});
171+
172+
return result.toUIMessageStreamResponse({
173+
onError: (err) => {
174+
console.error("STREAM ERROR:", err);
175+
return err instanceof Error ? err.message : "An error occurred while processing your request.";
176+
},
177+
});
178+
} catch (err) {
179+
console.error("API ERROR:", err);
180+
const errorMessage =
181+
err instanceof Error ? err.message : "An unexpected server error occurred.";
182+
const stream = createUIMessageStream({
183+
execute: ({ writer }) => {
184+
writer.write({
185+
type: "error",
186+
errorText: errorMessage,
187+
});
188+
},
189+
});
190+
return new Response(stream, {
191+
status: 200,
192+
headers: { "Content-Type": "text/plain; charset=utf-8" },
193+
});
194+
}
195+
}

0 commit comments

Comments
 (0)