Skip to content
Merged
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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ yarn-debug.log*
*.bak
tmp/
temp/

backend/output/
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ CONVEX_SELF_HOSTED_ADMIN_KEY=
# Required once any user-facing protected route is added.
# Same value as the frontend's CLERK_SECRET_KEY.
CLERK_SECRET_KEY=

# OpenRouter API key — required by the schema-inference CLI (npm run infer-schema).
# Generate at https://openrouter.ai/settings/keys
OPENROUTER_API_KEY=sk-or-...
130 changes: 129 additions & 1 deletion backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"infer-schema": "tsx src/cli.ts"
},
"dependencies": {
"@clerk/backend": "^3.4.11",
"@openrouter/ai-sdk-provider": "^2.9.0",
"@fastify/cors": "^11.0.0",
"convex": "^1.39.1",
"dotenv": "^16.4.0",
"fastify": "^5.0.0",
"fastify-plugin": "^5.1.0"
"ai": "^6.0.0",
"fastify-plugin": "^5.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.0.0",
Expand Down
36 changes: 36 additions & 0 deletions backend/prompts/schema-inference.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
You are a data engineering assistant that converts natural-language prompts into structured dataset schemas. Given a user prompt describing a dataset they want to build, you produce a precise schema definition.

Your job is to:

1. Identify the universe of entities the user wants to collect. Each entity becomes one row in the dataset.
2. Pick a clear primary key — the column whose values uniquely identify each row. This is usually a name, ID, or canonical URL. Exactly one column must have `is_primary_key: true`, and its `name` must equal `primary_key`. The primary key column must have `nullable: false` and `is_enumerable: true`.
3. Choose useful columns. Each column captures one fact about the entity. Use snake_case names. Mark `is_enumerable: true` only on columns whose values can be used to list all rows (typically just the primary key, and occasionally one or two others when a source page lists them alongside the primary key).
4. Set `retrieval_strategy`:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be too early in the agent workflow to suggest these without more context. In the current agent system, the agent would first use Tinyfish search to search for candidate urls and fetch those results for analysis of what is the best way to retrieve.

For the same reason, instead of source_hint, it may be helpful to generate search_query_hint.

Retrieval_hint is a great idea, however, because of the varied sources that Tinyfish search would propose, this may not be the best fit. This should be a decision left for downstream agent with more information.

My recommendation is to produce 4: 'search_queries": generate [X number] of search queries based on the user prompt and data spec. These search queries should be differentiated from each other.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — added your comments verbatim to the prompt file so we don't lose this context (commit f54e368). We'll revisit the schema fields (replacing retrieval_strategy/source_hint/retrieval_hint with search_queries) once the downstream agent work is further along and we have a clearer picture of how TinyFish Search fits into the flow.

- `search_fetch` — the data lives on a static page or sitemap that can be fetched as HTML.
- `browser` — the source is a JavaScript-heavy SPA, requires scroll/click to reveal items, or paginates client-side.
- `hybrid` — unclear; the pipeline will try search_fetch first and fall back to browser.
5. Set `source_hint` to a specific URL whenever possible (e.g. `https://www.ycombinator.com/companies?industry=Fintech`). Avoid vague descriptions.
6. Write a `retrieval_hint` for each column describing where/how the value can be found later. Downstream agents will use this to fill the column for each row.

Rules:

- `dataset_name` must be snake_case.
- All column `name` values must be snake_case and unique.
- Prefer concrete column choices over speculative ones — better to omit a column than guess wildly.

# @MMeteorL's comments/suggestions:
# This may be too early in the agent workflow to suggest these without more
# context. In the current agent system, the agent would first use Tinyfish
# search to search for candidate urls and fetch those results for analysis
# of what is the best way to retrieve.
#
# For the same reason, instead of source_hint, it may be helpful to generate
# search_query_hint.
#
# Retrieval_hint is a great idea, however, because of the varied sources
# that Tinyfish search would propose, this may not be the best fit. This
# should be a decision left for downstream agent with more information.
#
# My recommendation is to produce: 'search_queries': generate [X number] of
# search queries based on the user prompt and data spec. These search queries
# should be differentiated from each other.
Comment on lines +20 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove inline review-discussion text from the production prompt.

These lines are not ignored by the LLM; they become active prompt content and can conflict with the required schema contract, causing inconsistent outputs.

Suggested fix
-
-# `@MMeteorL`'s comments/suggestions:
-# This may be too early in the agent workflow to suggest these without more
-# context. In the current agent system, the agent would first use Tinyfish
-# search to search for candidate urls and fetch those results for analysis
-# of what is the best way to retrieve.
-#
-# For the same reason, instead of source_hint, it may be helpful to generate
-# search_query_hint.
-#
-# Retrieval_hint is a great idea, however, because of the varied sources
-# that Tinyfish search would propose, this may not be the best fit. This
-# should be a decision left for downstream agent with more information.
-#
-# My recommendation is to produce: 'search_queries': generate [X number] of
-# search queries based on the user prompt and data spec. These search queries
-# should be differentiated from each other.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# @MMeteorL's comments/suggestions:
# This may be too early in the agent workflow to suggest these without more
# context. In the current agent system, the agent would first use Tinyfish
# search to search for candidate urls and fetch those results for analysis
# of what is the best way to retrieve.
#
# For the same reason, instead of source_hint, it may be helpful to generate
# search_query_hint.
#
# Retrieval_hint is a great idea, however, because of the varied sources
# that Tinyfish search would propose, this may not be the best fit. This
# should be a decision left for downstream agent with more information.
#
# My recommendation is to produce: 'search_queries': generate [X number] of
# search queries based on the user prompt and data spec. These search queries
# should be differentiated from each other.
🧰 Tools
🪛 LanguageTool

[style] ~32-~32: Consider using a more formal alternative.
Context: ...decision left for downstream agent with more information. # # My recommendation is to produce: '...

(MORE_INFO)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/prompts/schema-inference.txt` around lines 20 - 36, The production
prompt contains inline review-discussion comments (the block beginning with "#
`@MMeteorL`'s comments/suggestions:" referencing Tinyfish, source_hint,
retrieval_hint, and recommending 'search_queries') that must be removed from
backend/prompts/schema-inference.txt; delete that review text so the file only
contains the formal schema-inference prompt content (move any useful reviewer
suggestions into the PR or separate design notes), and if you want to preserve
the actionable suggestion, replace the removed discussion with a concise, formal
prompt field such as "search_queries" guidance (not the reviewer conversation)
so functions like schema inference, search_queries, source_hint, and
retrieval_hint are defined explicitly and consistently.

42 changes: 42 additions & 0 deletions backend/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { join } from "node:path";

import { inferSchema } from "./pipeline/schema-inference.js";

function parsePrompt(argv: string[]): string {
const idx = argv.findIndex((a) => a === "--prompt");
if (idx === -1 || idx === argv.length - 1) {
throw new Error('Usage: npm run infer-schema -- --prompt "<your prompt>"');
}
const value = argv[idx + 1];
if (!value.trim()) throw new Error("--prompt requires a non-empty value");
return value;
}

function generateRunId(): string {
const ts = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
const rand = randomBytes(3).toString("hex");
return `${ts}-${rand}`;
}

async function main() {
const prompt = parsePrompt(process.argv.slice(2));
const runId = generateRunId();
const outDir = join("output", runId);

console.log(`Inferring schema for: "${prompt}"`);
const schema = await inferSchema(prompt);

mkdirSync(outDir, { recursive: true });
const schemaPath = join(outDir, "schema.json");
writeFileSync(schemaPath, JSON.stringify(schema, null, 2) + "\n");

console.log(`Run ID: ${runId}`);
console.log(`Schema: backend/${schemaPath}`);
}

main().catch((err) => {
console.error(err);
Comment on lines +28 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid logging raw prompts and raw error objects.

This CLI can handle sensitive prompt content; printing full prompt and unsanitized errors increases leakage risk in shared logs.

Suggested hardening
-  console.log(`Inferring schema for: "${prompt}"`);
+  console.log(`Inferring schema (prompt length: ${prompt.length})`);

 main().catch((err) => {
-  console.error(err);
+  const message = err instanceof Error ? err.message : String(err);
+  console.error(`infer-schema failed: ${message}`);
   process.exit(1);
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/cli.ts` around lines 28 - 40, The CLI currently prints the raw
prompt and raw error objects; update the main() flow so you no longer log the
full prompt variable or entire error objects: in the area around
inferSchema(prompt) and the console.log statements referencing prompt and runId,
replace printing the raw prompt with a redacted or truncated preview (e.g., show
only length or first N characters with "[REDACTED]" suffix) and keep runId only,
and in the main().catch handler replace console.error(err) with a sanitized
error log that emits only a short message, an error code/type, and optionally a
truncated stack or hashed fingerprint rather than the full error object. Ensure
changes reference the inferSchema call, runId, prompt variable, and main() catch
block so the reviewer can find and update the exact logging lines.

process.exit(1);
});
2 changes: 2 additions & 0 deletions backend/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ export const env = {
// today because no protected routes exist yet; required as soon as one is
// added.
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,

OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
};
48 changes: 48 additions & 0 deletions backend/src/pipeline/schema-inference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { readFileSync } from "node:fs";
import { generateText, Output, NoObjectGeneratedError } from "ai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";

import { env } from "../env.js";
import { datasetSchemaSchema, type DatasetSchema } from "./types.js";

const SYSTEM_PROMPT = readFileSync(
new URL("../../prompts/schema-inference.txt", import.meta.url),
"utf8",
);

function getModel() {
if (!env.OPENROUTER_API_KEY) {
throw new Error("Missing required environment variable: OPENROUTER_API_KEY");
}
const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY });
return openrouter("anthropic/claude-sonnet-4-6");
}

export async function inferSchema(prompt: string): Promise<DatasetSchema> {
const model = getModel();
try {
return await callOnce(model, prompt);
} catch (error) {
if (NoObjectGeneratedError.isInstance(error)) {
const detail = error.cause ? String(error.cause) : error.text;
const retry = `${prompt}\n\nYour previous output failed validation:\n${detail}\n\nReturn a corrected DatasetSchema.`;
return await callOnce(model, retry);
}
throw error;
}
}

async function callOnce(
model: Parameters<typeof generateText>[0]["model"],
prompt: string,
): Promise<DatasetSchema> {
const { output } = await generateText({
model,
output: Output.object({ schema: datasetSchemaSchema }),
system: SYSTEM_PROMPT,
maxTokens: 4096,
prompt,
});
if (!output) throw new Error("Model did not generate a valid schema object");
return output;
}
Loading