diff --git a/README.md b/README.md
index f1f36b2..c668156 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ only; it is not the report. `validate_spec` still says where each
The LLM's own reads during refinement (`describe_profile`'s code lists,
`lookup_values`, `explore_data` and `sample_spec`) run under a service
-scope, not the caller's session: `publicQuery` in `src/llm/lookup.js`. mu's
+scope, not the caller's session: `publicQuery` in `src/llm/explore.js`. mu's
`query(q, { scope })` sends `mu-auth-scope`; the sparql-parser config grants
that scope read access to `http://mu.semte.ch/graphs/public` only. So
refinement reads only the public graph, regardless of who calls. Execution
@@ -149,7 +149,7 @@ user sees is Dutch.
| `SEED_PAGE_SIZE` | `5000` | seed page size |
| `SUBJECT_CHUNK_SIZE` | `100` | see above |
| `SHARE_DIR` | `/share` | where CSVs are written |
-| `CHAT_ASSISTANT_URI` | — | the `prov:SoftwareAgent` that signs assistant messages; found by lookup when unset |
+| `CHAT_ASSISTANT_URI` | `http://data.lblod.info/id/chat-agents/rapportassistent` | the `prov:SoftwareAgent` that signs assistant messages (the portal's chat-assistant migration seeds it) |
| `CHAT_HISTORY_LIMIT` | `20` | messages of history a turn reads |
| `SERVICE_SCOPE` | `http://services.semantic.works/natural-language-report` | the mu-auth-scope for the LLM's own reads (code lists, `lookup_values`, `explore_data`, `sample_spec`). Must match the `with-scope` grant in the app's sparql-parser config. |
diff --git a/app.js b/app.js
index d8e5f86..f0fb50e 100644
--- a/app.js
+++ b/app.js
@@ -1,12 +1,14 @@
import { app, errorHandler } from 'mu';
import bodyParser from 'body-parser';
import { loadProfiles } from './src/runner/profile.js';
-import { readConversation, readHistory, findAssistant, writeMessage } from './src/chat.js';
+import { readConversation, readHistory, writeMessage } from './src/chat.js';
import { wantsExecution } from './src/llm/mode.js';
import { executeSpec, refineSpec } from './src/report-assistant.js';
const PROFILE_DIR = process.env.PROFILE_DIR || '/config/profiles';
-const CHAT_ASSISTANT_URI = process.env.CHAT_ASSISTANT_URI;
+// The agent that signs the assistant's messages, seeded by the portal's
+// chat-assistant migration.
+const CHAT_ASSISTANT_URI = process.env.CHAT_ASSISTANT_URI || 'http://data.lblod.info/id/chat-agents/rapportassistent';
const TITLE_LENGTH = 80;
const FAILED = 'Er ging iets mis. Probeer het opnieuw.';
@@ -22,13 +24,12 @@ app.post('/assistant/conversations/:id/turns', bodyParser.json(), async function
const content = String(req.body?.content ?? '').trim();
if (!content) return res.status(400).json({ error: 'content is required' });
- let conversation, assistant, history, question;
+ let conversation, history, question;
try {
// the access check: the caller's session decides what is readable
conversation = await readConversation(req.params.id);
if (!conversation) return res.status(404).json({ error: 'no such conversation' });
- assistant = CHAT_ASSISTANT_URI || await findAssistant();
- history = await readHistory(conversation.uri, assistant);
+ history = await readHistory(conversation.uri, CHAT_ASSISTANT_URI);
question = await writeMessage({
conversationUri: conversation.uri,
content,
@@ -50,13 +51,13 @@ app.post('/assistant/conversations/:id/turns', bodyParser.json(), async function
await writeMessage({
conversationUri: conversation.uri,
content: answer.text,
- maker: assistant,
+ maker: CHAT_ASSISTANT_URI,
attachments: answer.attachments,
});
} catch (e) {
console.error('[chat] turn failed:', e);
try {
- await writeMessage({ conversationUri: conversation.uri, content: FAILED, maker: assistant });
+ await writeMessage({ conversationUri: conversation.uri, content: FAILED, maker: CHAT_ASSISTANT_URI });
} catch (e2) {
console.error('[chat] could not write the failure message:', e2);
}
diff --git a/src/chat.js b/src/chat.js
index 33bb989..c2da6a7 100644
--- a/src/chat.js
+++ b/src/chat.js
@@ -25,7 +25,6 @@ const T = {
created: 'http://purl.org/dc/terms/created',
maker: 'http://xmlns.com/foaf/0.1/maker',
attachment: 'https://www.w3.org/ns/activitystreams#attachment',
- softwareAgent: 'http://www.w3.org/ns/prov#SoftwareAgent',
file: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject',
fileName: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName',
fileSize: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileSize',
@@ -109,17 +108,6 @@ export async function readHistory(conversationUri, assistantUri) {
return messages.slice(-HISTORY_LIMIT);
}
-// The one assistant the caller can read. Throws unless there is exactly one.
-export async function findAssistant() {
- const json = await query(`
- SELECT ?agent WHERE { ?agent ${u(T.type)} ${u(T.softwareAgent)} . } LIMIT 2`);
- const rows = bindings(json);
- if (rows.length !== 1) {
- throw new Error(`expected one prov:SoftwareAgent, found ${rows.length}; seed one or set CHAT_ASSISTANT_URI`);
- }
- return rows[0].agent.value;
-}
-
// --- writes -------------------------------------------------------------------
// One message, both types, its bijlagen, and the conversation touched, in
diff --git a/src/llm/describe.js b/src/llm/describe.js
index 4cf661c..37a49f8 100644
--- a/src/llm/describe.js
+++ b/src/llm/describe.js
@@ -1,32 +1,31 @@
// A profile as a plain text menu: entities, fields, code lists. Code lists up
// to INLINE_VALUES_MAX are inlined (and cached), so the LLM never has to
// search the short ones.
-import { codeList, INLINE_VALUES_MAX } from './lookup.js';
+import { codeList, INLINE_VALUES_MAX } from './explore.js';
export async function describeProfile(profile) {
- const lines = [];
- lines.push('A spec is Turtle, in exactly this shape (copy these prefixes):');
- lines.push('@prefix rep: .');
- lines.push('@prefix sh: .');
- lines.push('@prefix rdfs: .');
- lines.push('@prefix dct: .');
- lines.push('');
- lines.push('> a rep:ReportSpec , sh:NodeShape ;');
- lines.push(' dct:title "..." ;');
- lines.push(` rep:profile <${profile.uri}> ;`);
- lines.push(' sh:targetClass ;');
- lines.push(' sh:property [ ... ] ; # filters, optional');
- lines.push(' rep:columns ( [ sh:path ( ... ) ; rdfs:label "..." ] ... ) .');
- lines.push('Every column ends on a value field. Use rep:self as sh:path for the subject URI.');
- lines.push('For the URI of a linked node, end the column on that link and add sh:nodeKind sh:IRI to the column.');
- lines.push('');
- lines.push('sh:targetClass is the full URI in brackets after the entity below.');
- lines.push('In sh:path, use the predicate URI shown after each field name, not the field name itself.');
- lines.push('An inverse hop (the field shows "(inverse)") goes in the list as [ sh:inversePath ] .');
- lines.push('When two entities share one sh:targetClass, pick one with rep:entity .');
- lines.push('A date filter is sh:minInclusive or sh:maxInclusive with "yyyy-mm-dd"^^xsd:dateTime');
- lines.push('(the xsd prefix above is not copied: add @prefix xsd: ).');
- lines.push('');
+ const lines = [`A spec is Turtle, in exactly this shape (copy these prefixes):
+@prefix rep: .
+@prefix sh: .
+@prefix rdfs: .
+@prefix dct: .
+
+> a rep:ReportSpec , sh:NodeShape ;
+ dct:title "..." ;
+ rep:profile <${profile.uri}> ;
+ sh:targetClass ;
+ sh:property [ ... ] ; # filters, optional
+ rep:columns ( [ sh:path ( ... ) ; rdfs:label "..." ] ... ) .
+Every column ends on a value field. Use rep:self as sh:path for the subject URI.
+For the URI of a linked node, end the column on that link and add sh:nodeKind sh:IRI to the column.
+
+sh:targetClass is the full URI in brackets after the entity below.
+In sh:path, use the predicate URI shown after each field name, not the field name itself.
+An inverse hop (the field shows "(inverse)") goes in the list as [ sh:inversePath ] .
+When two entities share one sh:targetClass, pick one with rep:entity .
+A date filter is sh:minInclusive or sh:maxInclusive with "yyyy-mm-dd"^^xsd:dateTime
+(the xsd prefix above is not copied: add @prefix xsd: ).
+`];
for (const shape of profile.shapes) {
lines.push(`${shape.label || shape.targetClass} — sh:targetClass <${shape.targetClass}> ; rep:entity <${shape.uri}>`);
for (const f of shape.fields) {
diff --git a/src/llm/explore.js b/src/llm/explore.js
index 8a128c6..d1290e3 100644
--- a/src/llm/explore.js
+++ b/src/llm/explore.js
@@ -1,17 +1,24 @@
-// The LLM's exploration of the public data, beyond the code lists. Both
-// read under the service scope only (publicQuery, see lookup.js) and count
-// nothing:
+// Everything the LLM reads from the database in refine mode. Every query
+// goes through publicQuery: the service scope, not the caller's session.
+// The sparql-parser config grants that scope read access to the public
+// graph only, so these reads are the same for every caller. Nothing here
+// counts, and execution still runs as the caller.
+// - code lists: inlined in describe_profile, searched by lookup_values;
// - explore_data: the predicates, values and links of one target class;
// - sample_spec: the queries of a draft spec, for a few subjects.
// A suggestion, not a check: the report runs as the user and may find other
// data.
-import { sparqlEscapeUri } from 'mu';
-import { publicQuery } from './lookup.js';
+import { query, sparqlEscapeUri, sparqlEscapeString } from 'mu';
import { startShape } from '../runner/profile.js';
import { seedPageQuery } from '../runner/seed.js';
import { groupColumns, groupQuery, collect } from '../runner/columns.js';
import { assemble } from '../runner/assemble.js';
+// Must match the with-scope grant in the app's config.lisp.
+const SERVICE_SCOPE = process.env.SERVICE_SCOPE || 'http://services.semantic.works/natural-language-report';
+export const INLINE_VALUES_MAX = Number(process.env.INLINE_VALUES_MAX || 50);
+const VALUES_TTL = Number(process.env.VALUES_TTL || 3600) * 1000;
+const LOOKUP_LIMIT = 25;
const SAMPLE_SIZE = 10;
const MAX_ROWS = 30;
const EXAMPLES = 3;
@@ -19,6 +26,128 @@ const MAX_VALUE_LENGTH = 60;
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
const XSD = 'http://www.w3.org/2001/XMLSchema#';
+// class → { values, at }. Refreshed lazily when stale.
+const cache = new Map();
+
+function publicQuery(q) {
+ return query(q, { scope: SERVICE_SCOPE });
+}
+
+// codeList(type) → up to INLINE_VALUES_MAX [{ uri, label }], cached for
+// VALUES_TTL. Empty when the read fails; the LLM then uses rep:anyOf with
+// words.
+export async function codeList(type) {
+ const hit = cache.get(type);
+ if (hit && Date.now() - hit.at < VALUES_TTL) return hit.values;
+ let values = [];
+ try {
+ const r = await publicQuery(`SELECT ?uri ?label WHERE {
+ ?uri a <${type}> .
+ OPTIONAL { ?uri ?label . }
+ } ORDER BY ?label LIMIT ${INLINE_VALUES_MAX + 1}`);
+ values = r.results.bindings.slice(0, INLINE_VALUES_MAX).map(b => ({
+ uri: b.uri.value,
+ label: b.label?.value || b.uri.value,
+ }));
+ } catch (e) {
+ values = [];
+ }
+ cache.set(type, { values, at: Date.now() });
+ return values;
+}
+
+// lookupValues(profile, field, term) → the matches as text for the LLM: up
+// to LOOKUP_LIMIT labels and URIs, an exact label (case aside) first. field is
+// "shapeLabel.fieldLabel" as the LLM writes it; it resolves to the class to
+// search.
+export async function lookupValues(profile, field, term) {
+ const type = resolveClass(profile, field);
+ if (!type) return `field "${field}" not found in profile "${profile.title}"`;
+
+ // Search within the class first. A common word ("gemeente") also sits in
+ // hundreds of unrelated labels; an unscoped scan drowns the code.
+ let rows = await labelQuery(term, type);
+ // Code lists sometimes type their entries differently than the profile
+ // says (MAR codes are ext:supervision/Nomenclature, the profile says
+ // skos:Concept). Fall back to a scan over everything.
+ if (!rows.length) rows = await labelQuery(term, null);
+ // "Gent" must surface before "AGB Erfgoed Gent".
+ const lower = term.toLowerCase();
+ const exactRow = rows.find(r => r.label.toLowerCase() === lower);
+ let matches = (exactRow ? [exactRow, ...rows.filter(r => r !== exactRow)] : rows).slice(0, LOOKUP_LIMIT);
+ if (!matches.length) matches = await codeQuery(term);
+ if (!matches.length) return `no value matches "${term}" in ${field}.`;
+
+ const lines = matches.map(m => `${m.label} <${m.uri}>`);
+ if (matches.length >= LOOKUP_LIMIT) lines.push(`(${LOOKUP_LIMIT}+ matches, narrow the term)`);
+ const exact = matches.find(m => m.label.toLowerCase() === lower);
+ if (exact) lines.push(`exact: <${exact.uri}>`);
+ return lines.join('\n');
+}
+
+// Regex on str(?label), so language-tagged labels match.
+async function labelQuery(term, type) {
+ const q = `SELECT DISTINCT ?uri ?label WHERE {
+ ?uri a ${type ? `<${type}>` : '?anyType'} ;
+ ?label .
+ FILTER(REGEX(str(?label), ${sparqlEscapeString(escapeRegex(term))}, "i"))
+ } ORDER BY ?label LIMIT ${LOOKUP_LIMIT * 8}`;
+ const r = await publicQuery(q);
+ return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value }));
+}
+
+// MAR codes: the label says "MAR7300 - …" but the searchable code sits on
+// ext:supervision/nomenclatureCode. One extra query, only when the label
+// search came up empty.
+async function codeQuery(term) {
+ const q = `SELECT ?uri ?label WHERE {
+ ?uri ?code ;
+ ?label .
+ FILTER(CONTAINS(LCASE(str(?code)), LCASE(${sparqlEscapeString(term)})))
+ } ORDER BY ?label LIMIT ${LOOKUP_LIMIT}`;
+ try {
+ const r = await publicQuery(q);
+ return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value }));
+ } catch {
+ return [];
+ }
+}
+
+// "shapeLabel.fieldLabel" → the class to search: the field's sh:class, or
+// for a link (sh:node) the linked shape's sh:targetClass
+// ("melding.bestuurseenheid" → besluit:Bestuurseenheid).
+function resolveClass(profile, field) {
+ if (field.includes('/')) return null;
+ const dot = field.lastIndexOf('.');
+ if (dot < 0) return null;
+ const shapeLabel = field.slice(0, dot);
+ const fieldLabel = field.slice(dot + 1);
+ const shape = profile.shapes.find(s => s.label === shapeLabel);
+ if (!shape) return null;
+ const f = shape.fields.find(x => x.name === fieldLabel);
+ if (f?.class) return f.class;
+ if (f?.node) {
+ const linked = profile.shapes.find(s => s.uri === f.node);
+ return linked?.targetClass || null;
+ }
+ // The model sometimes writes shape.field on a linked shape
+ // ("bestuurseenheid.naam"); resolve it to that shape's target class.
+ for (const s of profile.shapes) {
+ if (s.label === shapeLabel || shortType(s.targetClass) === shapeLabel) {
+ return s.targetClass;
+ }
+ }
+ return null;
+}
+
+function shortType(cls) {
+ return cls.split('#').pop() || cls.split('/').pop();
+}
+
+function escapeRegex(s) {
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
// exploreClass(profile, cls) → text for the LLM. cls must be a
// sh:targetClass of the profile.
export async function exploreClass(profile, cls) {
@@ -47,14 +176,12 @@ export async function exploreClass(profile, cls) {
OPTIONAL { ?x a ?type . }
}`);
- const lines = [
- `A sample of ${subjects.length} <${cls}> from the public data. It shows what the data looks like;`,
- 'it is no check of a spec, and the report may run on other data.',
- 'Out = the instance points to it, In = something points to the instance.',
- '→ / ← give the class of the linked node: explore it to follow the path.',
- 'A predicate "not in the profile" cannot be used in a spec.',
- '',
- ];
+ const lines = [`A sample of ${subjects.length} <${cls}> from the public data. It shows what the data looks like;
+it is no check of a spec, and the report may run on other data.
+Out = the instance points to it, In = something points to the instance.
+→ / ← give the class of the linked node: explore it to follow the path.
+A predicate "not in the profile" cannot be used in a spec.
+`];
const out = new Map();
for (const b of outgoing.results.bindings) {
@@ -99,14 +226,12 @@ export async function sampleSpec(spec, profile) {
}
const [header, ...rows] = assemble(subjects, values, spec);
- return [
- `A sample from the public data: rows for up to ${SAMPLE_SIZE} subjects the spec finds there.`,
- 'The report runs as the user and may find other rows. Use it to see whether paths and filters',
- 'land where you expect, not to tell the user what the report will hold.',
- '',
- header.join(' | '),
- ...rows.slice(0, MAX_ROWS).map(row => row.map(showCell).join(' | ')),
- ].join('\n');
+ const table = [header.join(' | '), ...rows.slice(0, MAX_ROWS).map(row => row.map(showCell).join(' | '))];
+ return `A sample from the public data: rows for up to ${SAMPLE_SIZE} subjects the spec finds there.
+The report runs as the user and may find other rows. Use it to see whether paths and filters
+land where you expect, not to tell the user what the report will hold.
+
+${table.join('\n')}`;
}
function showCell(cell) {
diff --git a/src/llm/lookup.js b/src/llm/lookup.js
deleted file mode 100644
index afe4100..0000000
--- a/src/llm/lookup.js
+++ /dev/null
@@ -1,138 +0,0 @@
-// The LLM's own reads: code lists here, the data samples in explore.js.
-// They run under the service scope, not the caller's session; the
-// sparql-parser config grants that scope read access to the public graph
-// only, so they read the same for every caller. Execution still runs as the
-// caller.
-//
-// lookup_values is a suggest helper, not a spec check. It reads candidate
-// values the LLM can put in a filter; it never runs the spec and never sees
-// report rows.
-import { query, sparqlEscapeString } from 'mu';
-
-// Must match the with-scope grant in the app's config.lisp.
-const SERVICE_SCOPE = process.env.SERVICE_SCOPE || 'http://services.semantic.works/natural-language-report';
-export const INLINE_VALUES_MAX = Number(process.env.INLINE_VALUES_MAX || 50);
-const VALUES_TTL = Number(process.env.VALUES_TTL || 3600) * 1000;
-const LIMIT = 25;
-
-// class → { values, at }. Refreshed lazily when stale.
-const cache = new Map();
-
-export function publicQuery(q) {
- return query(q, { scope: SERVICE_SCOPE });
-}
-
-// codeList(type) → up to INLINE_VALUES_MAX [{ uri, label }], cached for
-// VALUES_TTL. Empty when the read fails; the LLM then uses rep:anyOf with
-// words.
-export async function codeList(type) {
- const hit = cache.get(type);
- if (hit && Date.now() - hit.at < VALUES_TTL) return hit.values;
- let values = [];
- try {
- const r = await publicQuery(`SELECT ?uri ?label WHERE {
- ?uri a <${type}> .
- OPTIONAL { ?uri ?label . }
- } ORDER BY ?label LIMIT ${INLINE_VALUES_MAX + 1}`);
- values = r.results.bindings.slice(0, INLINE_VALUES_MAX).map(b => ({
- uri: b.uri.value,
- label: b.label?.value || b.uri.value,
- }));
- } catch (e) {
- values = [];
- }
- cache.set(type, { values, at: Date.now() });
- return values;
-}
-
-// lookupValues(profile, field, term) → the matches as text for the LLM: up
-// to LIMIT labels and URIs, an exact label (case aside) first. field is
-// "shapeLabel.fieldLabel" as the LLM writes it; it resolves to the class to
-// search.
-export async function lookupValues(profile, field, term) {
- const type = resolveClass(profile, field);
- if (!type) return `field "${field}" not found in profile "${profile.title}"`;
-
- // Search within the class first. A common word ("gemeente") also sits in
- // hundreds of unrelated labels; an unscoped scan drowns the code.
- let rows = await labelQuery(term, type);
- // Code lists sometimes type their entries differently than the profile
- // says (MAR codes are ext:supervision/Nomenclature, the profile says
- // skos:Concept). Fall back to a scan over everything.
- if (!rows.length) rows = await labelQuery(term, null);
- // "Gent" must surface before "AGB Erfgoed Gent".
- const lower = term.toLowerCase();
- const exactRow = rows.find(r => r.label.toLowerCase() === lower);
- let matches = (exactRow ? [exactRow, ...rows.filter(r => r !== exactRow)] : rows).slice(0, LIMIT);
- if (!matches.length) matches = await codeQuery(term);
- if (!matches.length) return `no value matches "${term}" in ${field}.`;
-
- const lines = matches.map(m => `${m.label} <${m.uri}>`);
- if (matches.length >= LIMIT) lines.push(`(${LIMIT}+ matches, narrow the term)`);
- const exact = matches.find(m => m.label.toLowerCase() === lower);
- if (exact) lines.push(`exact: <${exact.uri}>`);
- return lines.join('\n');
-}
-
-// Regex on str(?label), so language-tagged labels match.
-async function labelQuery(term, type) {
- const q = `SELECT DISTINCT ?uri ?label WHERE {
- ?uri a ${type ? `<${type}>` : '?anyType'} ;
- ?label .
- FILTER(REGEX(str(?label), ${sparqlEscapeString(escapeRegex(term))}, "i"))
- } ORDER BY ?label LIMIT ${LIMIT * 8}`;
- const r = await publicQuery(q);
- return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value }));
-}
-
-// MAR codes: the label says "MAR7300 - …" but the searchable code sits on
-// ext:supervision/nomenclatureCode. One extra query, only when the label
-// search came up empty.
-async function codeQuery(term) {
- const q = `SELECT ?uri ?label WHERE {
- ?uri ?code ;
- ?label .
- FILTER(CONTAINS(LCASE(str(?code)), LCASE(${sparqlEscapeString(term)})))
- } ORDER BY ?label LIMIT ${LIMIT}`;
- try {
- const r = await publicQuery(q);
- return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value }));
- } catch {
- return [];
- }
-}
-
-// "shapeLabel.fieldLabel" → the class to search: the field's sh:class, or
-// for a link (sh:node) the linked shape's sh:targetClass
-// ("melding.bestuurseenheid" → besluit:Bestuurseenheid).
-function resolveClass(profile, field) {
- if (field.includes('/')) return null;
- const dot = field.lastIndexOf('.');
- if (dot < 0) return null;
- const shapeLabel = field.slice(0, dot);
- const fieldLabel = field.slice(dot + 1);
- const shape = profile.shapes.find(s => s.label === shapeLabel);
- if (!shape) return null;
- const f = shape.fields.find(x => x.name === fieldLabel);
- if (f?.class) return f.class;
- if (f?.node) {
- const linked = profile.shapes.find(s => s.uri === f.node);
- return linked?.targetClass || null;
- }
- // The model sometimes writes shape.field on a linked shape
- // ("bestuurseenheid.naam"); resolve it to that shape's target class.
- for (const s of profile.shapes) {
- if (s.label === shapeLabel || shortType(s.targetClass) === shapeLabel) {
- return s.targetClass;
- }
- }
- return null;
-}
-
-function shortType(cls) {
- return cls.split('#').pop() || cls.split('/').pop();
-}
-
-function escapeRegex(s) {
- return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-}
diff --git a/src/llm/tools.js b/src/llm/tools.js
index 0ce4ac5..22b0e92 100644
--- a/src/llm/tools.js
+++ b/src/llm/tools.js
@@ -5,8 +5,7 @@
import { parseSpec } from '../runner/spec.js';
import { checkSpec, whereNotes, profileError } from '../runner/check.js';
import { describeProfile } from './describe.js';
-import { lookupValues } from './lookup.js';
-import { exploreClass, sampleSpec } from './explore.js';
+import { lookupValues, exploreClass, sampleSpec } from './explore.js';
import { readSpecFile } from '../chat.js';
// The tool definitions sent to the model. The descriptions are what the LLM