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
19 changes: 13 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@ SPARQL_MODE=memory
# SPARQL_ENDPOINT=http://localhost:3030/envited/sparql

# --- LLM Provider ---
# "ollama" = Local Ollama instance (free, no API key)
# "openai" = OpenAI API (requires OPENAI_API_KEY)
# "copilot" = GitHub Copilot via @github/copilot-sdk (enterprise OAuth)
# "ollama" = Local Ollama instance (free, no API key)
# "openai" = OpenAI API (requires OPENAI_API_KEY)
# "anthropic" = Anthropic API (requires ANTHROPIC_API_KEY)
# "claude-cli" = Claude CLI session (reads token from ~/.claude/.credentials.json, no key needed)
# "copilot" = GitHub Copilot via @github/copilot-sdk (enterprise OAuth)
AI_PROVIDER=ollama

# --- Model ---
# ollama: qwen2.5-coder:7b (recommended), deepseek-r1:14b, llama3.3:8b
# openai: gpt-4o, gpt-4o-mini
# copilot: claude-opus-4.6, claude-sonnet-4, gpt-4o
# ollama: qwen2.5-coder:7b (recommended), deepseek-r1:14b, llama3.3:8b
# openai: gpt-4o, gpt-4o-mini
# anthropic: claude-sonnet-4-20250514, claude-haiku-4-5-20251001
# claude-cli: claude-sonnet-4-20250514, claude-haiku-4-5-20251001
# copilot: claude-opus-4.6, claude-sonnet-4, gpt-4o
AI_MODEL=qwen2.5-coder:7b

# --- Ollama (default provider) ---
Expand All @@ -34,6 +38,9 @@ OLLAMA_BASE_URL=http://localhost:11434/v1
# --- OpenAI (optional) ---
# OPENAI_API_KEY=sk-...

# --- Anthropic (optional, not needed for claude-cli) ---
# ANTHROPIC_API_KEY=sk-ant-...

# --- Ontology Source ---
# Fallback if submodules not initialized (git submodule update --init --recursive)
ONTOLOGY_REPO=ASCS-eV/ontology-management-base
Expand Down
86 changes: 86 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Natural language search over ENVITED-X simulation asset metadata. Users type queries like "German highways with 3 lanes"; an LLM fills structured `SearchSlots` (never writes SPARQL); a deterministic compiler generates SPARQL from those slots; Oxigraph executes the query. The ontology (OWL + SHACL) is the single source of truth for prompt generation, slot validation, and query compilation.

## Commands

```bash
pnpm install # Install all dependencies
pnpm dev # Start API (:3003) + web (:5174) + docs (:5173)
pnpm run validate # Full quality gate: typecheck + lint + format + tests
pnpm test # Unit tests (Vitest, all packages)
pnpm run test:e2e # E2E tests (Playwright)
pnpm run check-types # TypeScript type checking only
pnpm run lint # ESLint only
pnpm run format:check # Prettier check only
pnpm run build # Production build (all packages)
```

### Running a single package or test

```bash
pnpm --filter @ontology-search/search test # Tests for one package
pnpm --filter @ontology-search/api dev:clean # Dev server for one app
npx vitest run packages/search/src/__tests__/compiler.test.ts # Single test file
```

### Submodules

Ontology files come from a nested git submodule chain. If ontology data is missing:

```bash
git submodule update --init --recursive
```

## Architecture

**pnpm monorepo** with Turborepo. Strict layered dependencies — no cycles allowed:

```
core (zero deps) <-- sparql, ontology <-- search <-- llm <-- api (app)
web (app, frontend only)
```

### Key pipeline (search flow)

1. **Schema loading** (startup) — `schema-loader.ts` loads 45 OWL+SHACL files into `<urn:graph:schema>` named graph
2. **Prompt generation** — `prompt-builder.ts` embeds raw SHACL Turtle into the LLM system prompt
3. **LLM interpretation** — Agent calls `submit_slots` tool producing `SearchSlots` (domains, filters, ranges, gaps)
4. **Slot validation** — `slot-validator.ts` fuzzy-matches filter values against `sh:in` vocabulary, corrects domains via property→domain map
5. **SPARQL compilation** — `compiler.ts` generates deterministic SPARQL using `CompilerVocab` from `schema-queries.ts`
6. **Execution** — Oxigraph WASM (dev) or Fuseki (prod) runs the query
7. **SSE streaming** — Results streamed as Server-Sent Events to React UI

### Critical invariant

The LLM never writes SPARQL. It fills `SearchSlots` via a single tool call. The compiler is deterministic — same slots always produce the same query. This is the security model: no prompt injection can produce arbitrary queries.

### Package workspace names

| Directory | Workspace name | Role |
| ------------------- | --------------------------- | ----------------------------------------------- |
| `packages/core` | `@ontology-search/core` | Config (Zod), logging, errors |
| `packages/sparql` | `@ontology-search/sparql` | Oxigraph WASM + remote store + LRU cache |
| `packages/ontology` | `@ontology-search/ontology` | Source resolution, vocabulary indexing |
| `packages/search` | `@ontology-search/search` | Schema queries, compiler, service orchestration |
| `packages/llm` | `@ontology-search/llm` | Prompt builder, slot validator, AI agents |
| `packages/testing` | `@ontology-search/testing` | Shared test helpers (not production) |
| `apps/api` | `@ontology-search/api` | Hono SSE API server |
| `apps/web` | `@ontology-search/web` | Vite + React 19 + TanStack Router frontend |
| `apps/e2e` | `@ontology-search/e2e` | Playwright E2E tests |
| `apps/docs` | `@ontology-search/docs` | VitePress documentation |

## Conventions

- **Commits**: Conventional Commits format, signed (`git commit -s -S`). Enforced by commitlint hook.
- **Pre-commit hook**: runs `lint-staged` (ESLint fix + Prettier on staged files). Does NOT run tests or type-checking — run `pnpm run validate` before pushing.
- **TypeScript**: strict mode, `noUncheckedIndexedAccess` enabled, target ES2022, module NodeNext.
- **Imports**: sorted by `eslint-plugin-simple-import-sort` (auto-fixed on commit).
- **No `console.log`**: use `console.warn`/`console.error`, or the structured logger from `@ontology-search/core`.
- **No `any`**: `@typescript-eslint/no-explicit-any` is a warning.
- **Environment**: Node >= 22. Config via `.env.local` (copied from `.env.example`). Validated by Zod at startup.
- **Schema metadata is graph-driven**: `schema-queries.ts` discovers domains, properties, and shape groups from SHACL at runtime — do not hardcode domain metadata.
9 changes: 6 additions & 3 deletions apps/api/src/__tests__/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { describe, expect, it, vi } from 'vitest'

import { app } from '../app.js'

vi.mock('@ontology-search/search', () => ({
vi.mock('../search-factory.js', () => ({
searchNl: vi.fn(),
searchRefine: vi.fn(),
}))

vi.mock('@ontology-search/search', () => ({
getInitializedStore: vi.fn(),
compileCountQuery: vi.fn(),
getAssetDomains: vi.fn().mockResolvedValue(new Set(['hdmap', 'scenario'])),
Expand All @@ -29,7 +32,7 @@ describe('GET /health', () => {

describe('POST /search/stream', () => {
it('returns 200 for valid request', async () => {
const { searchNl } = await import('@ontology-search/search')
const { searchNl } = await import('../search-factory.js')
vi.mocked(searchNl).mockResolvedValue({
interpretation: { query: 'test', intent: 'search', domains: ['hdmap'] },
gaps: [],
Expand Down Expand Up @@ -85,7 +88,7 @@ describe('POST /search/refine', () => {
})

it('returns 200 with results for valid slots', async () => {
const { searchRefine } = await import('@ontology-search/search')
const { searchRefine } = await import('../search-factory.js')
vi.mocked(searchRefine).mockResolvedValue({
sparql: 'SELECT * WHERE { ?s ?p ?o }',
execution: { results: [{ id: '1' }], error: undefined },
Expand Down
13 changes: 8 additions & 5 deletions apps/api/src/routes/search.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { badRequest, internalError, unprocessable } from '@ontology-search/core/errors'
import { REQUEST_ID_HEADER, RequestLogger } from '@ontology-search/core/logging'
import { searchNl, searchRefine } from '@ontology-search/search'
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
import { z } from 'zod'

import { searchNl, searchRefine } from '../search-factory.js'
import type { AppEnv } from '../types.js'

const searchSlotsSchema = z.object({
Expand All @@ -15,10 +15,13 @@ const searchSlotsSchema = z.object({
.default({}),
location: z
.object({
country: z.string().optional(),
state: z.string().optional(),
region: z.string().optional(),
city: z.string().optional(),
// Each field accepts a single value or an array of values. Arrays let
// a refine caller express a region (e.g. country: ["DE","FR","IT"])
// and have the compiler emit `FILTER(?country IN (...))`.
country: z.union([z.string(), z.array(z.string())]).optional(),
state: z.union([z.string(), z.array(z.string())]).optional(),
region: z.union([z.string(), z.array(z.string())]).optional(),
city: z.union([z.string(), z.array(z.string())]).optional(),
})
.optional(),
license: z.string().optional(),
Expand Down
95 changes: 95 additions & 0 deletions apps/api/src/search-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Production wiring for SearchService (composition root).
*
* This is the API app's responsibility: wiring @ontology-search/llm into
* @ontology-search/search. The search package defines the interfaces;
* this module provides the concrete implementations.
*
* Tests construct SearchService directly with mock dependencies.
*/
import { generateStructuredSearch } from '@ontology-search/llm'
import { validateRangesAgainstShacl, validateSlotsAgainstShacl } from '@ontology-search/llm'
import { ShaclValidator } from '@ontology-search/ontology/shacl-validator'
import type {
NlSearchOptions,
RefineOptions,
RefineResult,
SearchResult,
SearchSlots,
} from '@ontology-search/search'
import {
compileAllCountQueries,
compileSlots,
extractVocabulary,
getInitializedStore,
type SearchDependencies,
SearchService,
} from '@ontology-search/search'
import { enforceSparqlPolicy } from '@ontology-search/sparql/policy'

let instance: SearchService | null = null

/**
* Get the singleton SearchService instance with production dependencies.
*/
export async function getSearchService(): Promise<SearchService> {
if (instance) return instance

const deps: SearchDependencies = {
getStore: getInitializedStore,
interpretQuery: generateStructuredSearch,
compileSlots,
compileCountQueries: compileAllCountQueries,
enforcePolicy: enforceSparqlPolicy,
validateSlots: async (slots: SearchSlots): Promise<SearchSlots> => {
// Defense-in-depth gate for /refine: run the same SHACL validator the
// LLM agent uses, then re-emit the slots with any violating values
// dropped. The compiler will see only ontology-valid filters/location.
const shacl = await ShaclValidator.fromWorkspace()
const store = await getInitializedStore()
const vocabulary = await extractVocabulary(store)
const result = await validateSlotsAgainstShacl(
slots.filters ?? {},
slots.location,
slots.license,
shacl,
vocabulary
)
// Drop ranges with property names not in the schema (e.g. `numberLanes`).
const rangeResult = validateRangesAgainstShacl(slots.ranges ?? {}, shacl)
return {
...slots,
filters: result.filters,
ranges: rangeResult.ranges,
location: result.location,
license: result.license,
}
},
}

instance = new SearchService(deps)
return instance
}

/** Reset singleton (for testing only) */
export function resetSearchService(): void {
instance = null
}

/**
* Full natural-language search pipeline.
* Delegates to singleton SearchService.
*/
export async function searchNl(options: NlSearchOptions): Promise<SearchResult> {
const service = await getSearchService()
return service.searchNl(options)
}

/**
* Refine search pipeline (no LLM).
* Delegates to singleton SearchService.
*/
export async function searchRefine(options: RefineOptions): Promise<RefineResult> {
const service = await getSearchService()
return service.searchRefine(options)
}
14 changes: 13 additions & 1 deletion apps/api/src/warmup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createComponentLogger } from '@ontology-search/core/logging'
import { warmupLlmSession } from '@ontology-search/llm'
import { buildSystemPrompt } from '@ontology-search/llm/prompt-builder'
import { ShaclValidator } from '@ontology-search/ontology/shacl-validator'
import { getInitializedStore } from '@ontology-search/search'
import { getShaclContent } from '@ontology-search/search/shacl-reader'

Expand All @@ -20,12 +21,23 @@ export async function warmup(): Promise<void> {
buildSystemPrompt(shaclContent)
const vocabMs = Date.now() - vocabStart

// Pre-construct the SHACL validator so the first search doesn't pay
// the shape-parse cost (~7s on the full ENVITED-X ontology).
const shaclStart = Date.now()
await ShaclValidator.fromWorkspace()
const shaclMs = Date.now() - shaclStart

// Pre-create the Copilot SDK session (~6s) so first query is instant
const sessionStart = Date.now()
await warmupLlmSession()
const sessionMs = Date.now() - sessionStart

logger.info(`Warmup complete in ${Date.now() - start}ms`, { storeMs, vocabMs, sessionMs })
logger.info(`Warmup complete in ${Date.now() - start}ms`, {
storeMs,
vocabMs,
shaclMs,
sessionMs,
})
} catch (error) {
logger.error('Warmup failed — service may respond slowly to first request', error)
}
Expand Down
Loading
Loading