Jevflake lets Snowflake ask questions about your data using Jev, the decision model from TypeSafe AI. It is a dbt package, with a Terraform module for teams that manage Snowflake that way.
Jev does not write text. You give it a row and a typed question. It gives back a typed answer with a probability. That makes it a good fit for SQL: the answer is a number or a label you can filter, join, and test.
This package does three things:
- Sets up Snowflake so it is allowed to call the Jev API.
- Creates SQL functions that call Jev:
jev_noul,jev_choice,jev_score, andjev_ask. - Gives you dbt macros and tests so answers are stored once, reused, and checked like any other model.
If you manage Snowflake with Terraform, there is a Terraform module that does steps 1 and 2 instead of dbt.
This project is not affiliated with TypeSafe AI, Snowflake, or dbt Labs.
- Noul is a yes or no question. The answer is a probability from 0 to 1 that the answer is yes.
- Choice picks one option from a fixed list. The answer has the pick, a probability for each option, and a confidence number.
- Score rates the row against ordered levels. The answer has a score, probabilities, and a confidence number. The score is the probability weighted average of the level numbers, so three levels give a score from 0 to 2.
- A Snowflake account with external access turned on. Trial accounts have it off by default, and your Snowflake account representative has to turn it on.
- A role that can create an integration. By default that is
ACCOUNTADMIN. - A TypeSafe API key from https://console.typesafe.ai.
- dbt 1.8 or newer with
dbt-snowflake. It has been tested on dbt 1.12.
The Python function needs the pandas and requests packages. Snowflake installs them for you. Depending on the account, they come from Snowflake's PyPI repository or from its Anaconda channel. If creating the function fails with a message about Anaconda terms, an ORGADMIN has to accept those terms once in Snowsight.
Run this once in Snowflake. The package never sees the key itself, so it never lands in dbt logs.
create schema if not exists analytics.jevflake;
create secret analytics.jevflake.jev_api_key
type = generic_string
secret_string = 'your-typesafe-api-key';Use your own database in place of analytics. By default the package looks in your dbt target database, in a schema called jevflake.
packages:
- git: "https://github.com/KranzL/Jevflake.git"
revision: v0.1Then run dbt deps. Pin a tag rather than main so updates never change your SQL under you. Changes between versions are listed in CHANGELOG.md.
dbt run-operation jevflake.setup --args '{grant_to: [transformer]}'This creates:
- the schema if it does not exist yet
- a network rule that allows traffic to
api.typesafe.aionly - an external access integration called
jev_access - the SQL functions
- for each role in
grant_to: usage on the schema and on the functions
A role in grant_to can call the functions with only those grants, plus usage on the database and a warehouse. It does not need access to the integration or the secret. If a role lacks usage on the database, an admin can grant it:
grant usage on database analytics to role reporter;You can run setup again at any time, and you should after you change any setting below. Setup recreates the functions, and Snowflake drops the old grants when it does, so setup reads back the roles that already had access and grants them again along with grant_to. Pass preserve_grants: false to grant exactly grant_to and nothing else. To verify who can use the functions afterwards:
dbt run-operation jevflake.check_grants --args '{grant_to: [transformer, reporter]}'This fails if any listed role is missing access, and logs any other roles that have it.
To see the SQL without running it:
dbt run-operation jevflake.setup --args '{dry_run: true}'Database, schema, role, and column names are used unquoted. They must be plain identifiers made of letters, digits, and underscores, starting with a letter or underscore, and the package fails at compile time otherwise. The string and mapping forms of state are raw SQL expressions by design and are not checked.
Split the work. An admin runs the first command. The dbt role runs the second.
dbt run-operation jevflake.setup_network --args '{grant_to: [transformer], callers: [reporter]}'
dbt run-operation jevflake.setup_functions --args '{grant_to: [reporter]}'- In
setup_network,grant_tolists the roles that will create the functions. They get usage on the integration, read on the secret, and usage and create function on the schema.callerslists the roles that will call the functions. They get usage on the schema, which only the schema owner can grant. - In
setup_functions,grant_tolists the roles that will call the functions. They get usage on each function.
If the secret lives in a different schema than the functions, the role that creates the functions also needs usage on that schema.
To remove the functions, the integration, and the network rule:
dbt run-operation jevflake.teardownThe secret and the schema are left in place.
select
ticket_id,
analytics.jevflake.jev_noul(body, 'The customer is asking for a refund') as wants_refund
from support_tickets;select
ticket_id,
analytics.jevflake.jev_choice(
body,
'Which team should handle this ticket',
parse_json('{"billing": "Charges and refunds", "technical": "Bugs and errors"}')
) as answer
from support_tickets;The arguments are what Jev reads, the instructions, and the criteria:
jev_noul(state, instructions)returns a float. An optional third argument describes what counts as yes and no:parse_json('{"true": "...", "false": "..."}').jev_choice(state, instructions, criteria)returns a variant. The criteria is an object that maps each option to a short description.jev_score(state, instructions, criteria)returns a variant. The criteria is an array of level descriptions, lowest first.
jev_choice and jev_score return the whole answer. Read parts of it with answer:choice, answer:score, answer:confidence, answer:probabilities, and for a score answer:legend.
What Jev reads must be text, an object, or an array. Pass a text column as it is. To send more than one column, pass object_construct('subject', subject, 'body', body). Jev rejects a bare number, date, or boolean, and the answer comes back null. Cast those to text first, for example amount::varchar, or put them inside an object.
jev_ask takes many named questions at once and makes one API call per row. Use it when you have more than one question about the same row. It is cheaper and faster than separate calls, because the row is only sent once.
select
ticket_id,
analytics.jevflake.jev_ask(
object_construct('subject', subject, 'body', body),
parse_json('{
"ticket_type": {
"type": "choice",
"instructions": "Which team should handle this ticket",
"criteria": {"billing": "Charges and refunds", "technical": "Bugs and errors"}
},
"blocked": {
"type": "noul",
"instructions": "The customer is blocked from running their business"
}
}')
):answers as answers
from support_tickets;The result has one entry per question name, for example answers:ticket_type:choice and answers:blocked:noul.
You will also see a function called jev_ask_json in the schema. It is the Python function the others are built on. You do not need to call it.
This is the recommended way. Answers are stored in a table, one row per key and question. A row is only sent to Jev again if its content changes, one of its questions changes, or you change jevflake_model. New or changed rows are asked every question at once; rows that are otherwise up to date are only re-asked the questions that changed.
{{ config(
materialized='incremental',
unique_key=['ticket_id', 'question']
) }}
{{ jevflake.judgments(
relation=ref('stg_support_tickets'),
key='ticket_id',
state=['subject', 'body'],
questions={
'ticket_type': jevflake.choice_question(
'Which team should handle this ticket',
{
'billing': 'Charges, refunds, invoices, plans',
'technical': 'Bugs, crashes, errors, slow pages',
'account': 'Login, access, profile changes'
}
),
'urgency': jevflake.score_question(
'How urgent is this ticket',
['No time pressure', 'Normal queue', 'Customer is blocked right now']
),
'has_contact_info': jevflake.noul_question(
'The text contains a phone number or an email address'
)
}
) }}Arguments:
relationis the model or source to read.keyis the column, or list of columns, that identifies a row.stateis what Jev gets to read. It can be one SQL expression, a list of columns, or a mapping of labels to SQL expressions. Send only the columns the questions need. TypeSafe's docs say unrelated content lowers accuracy, and in testing the same question gave noticeably different probabilities with and without an extra column.questionsis a mapping of names to questions. All of them go out in one API call per row.
Columns in the result: your key columns, question, answer_type, noul, choice, score, confidence, probabilities, error, answer, model, state_hash, question_hash, questions_hash, judged_at. question_hash covers one question plus the model name and is what decides whether that question is asked again. questions_hash covers the whole question set and is kept for backwards compatibility.
Things to know about what gets stored:
- If you change one question, every row is asked that question again on the next run, and only that question. Unchanged questions keep their stored answers.
- If you remove or rename a question, its old rows stay until you prune them (see below) or run the model with
--full-refresh. The same goes for rows that are deleted from the source. - Tables built before
question_hashexisted are picked up without a re-ask: rows whose storedquestions_hashstill matches are treated as up to date. - Rows with a null key are skipped, and so are rows where the whole state is null: a null expression, or a list or mapping where every column or expression is null. Skipped rows are never sent to Jev and leave no row in the result.
- The
modelcolumn holds the versioned model ID the API reports, falling back to your configuredjevflake_model.
select
ticket_id,
{{ jevflake.noul('body', 'The customer is asking for a refund') }} as wants_refund,
{{ jevflake.choice('body', 'What is the tone', ['calm', 'frustrated', 'happy']) }} as tone,
{{ jevflake.score('body', 'How urgent is this', ['low', 'medium', 'high']) }} as urgency
from {{ ref('stg_support_tickets') }}The first argument is a SQL expression written as a string, a list of columns, or a mapping, the same as state above. noul gives the probability as a float, choice gives the chosen label as text, and score gives the score as a float. For choice you can pass a list of options, or a mapping of options to descriptions.
Each macro is one API call per row, every time the model runs. Make the model incremental, or use a judgments model, so you do not pay for the same rows twice.
Some answers should go to a person. This macro selects errors, yes or no answers in the uncertain middle, and choices or scores with low confidence.
{{ jevflake.review_queue(ref('ticket_judgments'), noul_low=0.2, noul_high=0.8, min_confidence=0.5) }}When you remove or rename a question, or rows disappear from the source, the old judgment rows stay until you delete them. This removes both in one step:
dbt run-operation jevflake.prune_orphans --args '{judgments: ANALYTICS.PUBLIC.ticket_judgments, relation: ANALYTICS.PUBLIC.stg_support_tickets, key: ticket_id, questions: [ticket_type, urgency, has_contact_info]}'judgments is the judgments table, relation is the source it was built from, key is the key column or columns, and questions is the current question names, or the full questions mapping. Pass dry_run: true to print the deletes without running them. Nothing is sent to Jev, so pruning costs nothing but warehouse time.
These run on a judgments model. They read stored answers, so running tests costs nothing.
models:
- name: ticket_judgments
data_tests:
- jevflake.no_errors
- jevflake.noul_between:
arguments:
question: has_contact_info
max_value: 0.2
- jevflake.confidence_at_least:
arguments:
question: ticket_type
threshold: 0.5
config:
severity: warnno_errorsfails on rows Jev rejected, for example text that is too long.noul_betweenfails when a yes or no probability is outsidemin_valueandmax_value. They default to 0 and 1.score_betweenfails when a score is outsidemin_valueandmax_value.min_valuedefaults to 0;max_valueis required and is the number of levels minus one.choice_allowedfails when a choice is not inallowed. Leave outquestionto check every choice in the model.confidence_at_leastfails when a choice or score has confidence belowthreshold. Leave outquestionto check every choice and score in the model.judgment_driftfails when answers moved since a baseline run: give it the baseline table name, the key columns, the question, and atolerancefor noul and score probabilities (default 0.05). Choice answers fail on any change, and any answer type change fails. Snapshot the judgments table before a question or model change, then pointbaselineat the snapshot.
The arguments key needs dbt 1.10.5 or newer. On older versions, put the test arguments directly under the test name.
Set these as vars in your dbt_project.yml. Run jevflake.setup again after changing any of them.
jevflake_database: where the functions live. Default: your target database.jevflake_schema: defaultjevflake.jevflake_secret: full name of the secret. Default<database>.<schema>.jev_api_key.jevflake_integration: defaultjev_access. Integrations are account level, so the name must be unique in the account.jevflake_network_rule: defaultjev_egress.jevflake_model: defaultjev-1.13.0. It is pinned so answers do not shift under you. The model name is part of every cache key, so changing it asks every row every question again on the next run. Snapshot the judgments table first if you want to compare:judgment_driftwill tell you what moved.jevflake_concurrency: API calls in flight per batch. Default8. Snowflake can run several batches at once, so the total can be higher.jevflake_max_batch_size: the most rows Snowflake hands the function at once. Default64.jevflake_max_retries: default6.jevflake_timeout_seconds: default30.jevflake_rows_per_request: default1. See below.jevflake_python_version: default3.11.
By default each row is its own API call. At the time of writing TypeSafe allows 1,200 calls per minute, so that is the speed limit: about 72,000 rows per hour. TypeSafe says its limits can change, so check their models page.
Setting jevflake_rows_per_request higher packs several rows into one call. It is faster. It is also experimental: rows in the same call can influence each other, and Jev's own docs say unrelated content lowers accuracy. In a direct API test with three rows, the packed answers were within 0.01 of the single row answers and used about half the tokens. That is a tiny sample, and the packed path has not been run inside Snowflake. Compare answers on a sample of your own data before you trust it.
To compare, build the same judgments model twice on a sample, once with each setting, then run:
{{ jevflake.packing_drift(ref('ticket_judgments_single'), ref('ticket_judgments_packed'), 'ticket_id') }}This reports per question the mean and max absolute drift for noul and score answers, plus choice and answer type mismatches. Small drift on your own sample is the signal that packing is safe for that question set.
- Jev costs $0.042 per million input tokens. Output is free. Every call carries fixed overhead: in testing, one sentence of text with one short question used about 290 input tokens. A short support ticket with three questions came to about 470 input tokens, which works out to roughly $20 per million rows.
- The Snowflake warehouse keeps running while it waits on the API. A small warehouse is enough. A bigger one does not help, because the API rate limit is the cap.
- Jev reads up to 32k tokens of state plus the longest question per call, within 64k tokens for state plus all questions combined.
- Snowflake gives the function 180 seconds per batch of rows. If heavy rate limiting pushes a batch past that, the query fails. Lower
jevflake_max_batch_sizeif you see it. - Busy or rate limited calls are retried with backoff. If retries run out, the query fails. A bad API key fails the query right away.
- Rows that Jev rejects do not fail the query. In a judgments model they come back with
answer_type = 'error'and the reason inerror. In plain SQL,jev_noulreturns null for them, andjev_choice,jev_score, andjev_askreturn an answer withtypeset toerror.
This reads your source table and estimates tokens and dollars for one full pass, using your real state sizes. It sends nothing to Jev.
{{ jevflake.estimate_cost(ref('stg_support_tickets'), ['subject', 'body'], {'ticket_type': jevflake.choice_question('Which team', ['billing', 'technical'])}, key='ticket_id') }}The estimate assumes four characters per token plus a fixed overhead per call. Both are parameters, along with the price, so recalibrate them against your first bill.
Two settings control speed: jevflake_concurrency is API calls in flight per batch, and jevflake_max_batch_size is rows per batch. With the defaults of 8 and 64, one batch makes up to 64 calls, 8 at a time. Snowflake runs several batches in parallel, so the real call rate is higher than one batch suggests.
- The binding constraint is usually TypeSafe's rate limit, currently 1,200 calls per minute, or about 72,000 rows per hour at one row per call. Raising concurrency past what the limit allows only buys retries.
- If batches time out after 180 seconds under rate limiting, lower
jevflake_max_batch_sizeso each batch has less work to finish, and consider loweringjevflake_concurrencyto smooth the call rate. - If you are well under the rate limit and the warehouse is idle between calls, raise
jevflake_concurrencyfirst. Runjevflake.setupagain after changing either setting.
This summarizes a judgments table by question and answer type, with mean confidence and the judged range:
{{ jevflake.judgment_run_stats(ref('ticket_judgments')) }}Errors show up as answer_type = 'error' rows. For the uncertain middle and low confidence picks, see review_queue above.
From TypeSafe's own notes on Jev 1.13:
- Counting, comparing numbers, and ordering dates. Do those in SQL.
- Questions with double negatives or several steps of reasoning.
- It answers the question you wrote, not the one you meant. Be literal and specific.
- Asking a question and its opposite will not give answers that add up. Pick one phrasing.
- Text inside a row can steer the answer. Do not let an answer trigger anything destructive without a check.
Two more things seen in testing. The same question can give slightly different probabilities depending on which other questions are in the same call, by a point or two. Running the identical call twice gave identical answers, but TypeSafe's docs do not promise that.
Row content is sent to TypeSafe's API. It leaves Snowflake. Read TypeSafe's terms and privacy policy, and check with your security team before pointing this at sensitive data.
The terraform folder has a module that creates the network rule, the integration, the functions, and the grants, and optionally the schema and the secret. If you use it, skip step 3 above and point the dbt vars jevflake_database and jevflake_schema at the schema Terraform made. The dbt macros and tests work the same either way.
integration_tests/ is a small dbt project with ten sample support tickets. It needs the setup above, and these environment variables for a Snowflake user with key pair authentication: SNOWFLAKE_ACCOUNT, SNOWFLAKE_USER, SNOWFLAKE_PRIVATE_KEY_PATH, SNOWFLAKE_ROLE, SNOWFLAKE_WAREHOUSE, SNOWFLAKE_DATABASE, and optionally SNOWFLAKE_SCHEMA.
cd integration_tests
dbt deps --profiles-dir .
dbt build --profiles-dir .This sends the ten tickets to Jev, which costs a fraction of a cent. Two of the tests are set to warn, and you should expect them to: one sample ticket really does contain contact details, and one is a close call between two teams.
The offline checks need no Snowflake account:
python3 -m venv .venv
.venv/bin/pip install jinja2
.venv/bin/python -m unittest discover -s testsThey cover the Python handler, render every macro and generic test against snapshots in tests/snapshots, and check that the Terraform copy of the handler matches the dbt macro. Without jinja2 the render tests skip and the rest still runs with no packages. After changing a macro, regenerate snapshots with UPDATE_SNAPSHOTS=1 and review the diff before committing. After changing macros/setup/handler.sql, run python3 scripts/sync_terraform_handler.py.
CI runs the same checks on Python 3.10 through 3.12, plus dbt parse of the example project on dbt 1.10 and 1.12, an offline dbt compile through real dbt 1.10 using a duckdb profile, and Terraform validate on every example. The Snowflake adapter always opens a connection during compile, so compile runs against duckdb: it verifies the Jinja rendering, not the SQL dialect.
Version 0.1. It has been run against one live Snowflake account with a real Jev key, on dbt 1.12.5 with dbt-snowflake 1.12.1.
What was run: setup, the split setup with a separate admin role, function builder role, and caller role, teardown, every SQL function, the example project with its tests, a second run that sent no rows back to Jev, a run after editing one ticket that sent only that ticket, and the Terraform module.
What has not been run: large tables, so rate limit behaviour at volume is untested. Packing several rows into one call has not been run inside Snowflake. dbt versions older than 1.12 have not been tried.
MIT