A privacy-preserving natural language interface for querying Linked Data (JSON-LD). Users ask questions in plain language; the service translates them into SPARQL, enforces differential privacy guarantees, and returns noisy, human-readable answers.
- Overview
- Service Workflow
- Technical Component Reference
- Data Models
- Environment Configuration
- API Endpoints
- Privacy Rules (R1–R8)
- Current Limitations & Open Tasks
The Talk-to-Data Service allows users to ask natural language questions against a Linked Data dataset while enforcing differential privacy (DP). The service:
- Fetches an ontology (schema definition with sensitivity annotations) from a remote SOYA endpoint.
- Translates a user question into a SPARQL query via an LLM.
- Evaluates the query against six static privacy rules (R1–R6) to prevent sensitive data leakage.
- Checks and deducts from a global privacy budget (ε-based).
- Executes the SPARQL query on the provided JSON-LD data.
- Applies Laplace noise to aggregate results and suppresses small groups.
- Generates a natural language response using an LLM with numeric output validation.
- Returns the response together with session metadata and remaining budget.
User Question + JSON-LD Data + Ontology URL
│
▼
┌──────────────────────────────────────────────────┐
│ OrchestratorService │
│ │
│ 1. Fetch Ontology │
│ 2. Generate SPARQL (LLM) │
│ 3. Evaluate Query (Rules R1–R6) │
│ 4. Check Privacy Budget │
│ 5. Deduct Budget │
│ 6. Execute SPARQL (rdflib) │
│ 7. Add Laplace Noise │
│ 8. Suppress Small Groups │
│ 9. Generate NL Response (LLM + validation) │
│ 10. Return Response │
└──────────────────────────────────────────────────┘
│
▼
JSON response: { response, sessionId, remainingPrivacyBudget, data, ... }
The orchestrator (OrchestratorService.talk_to_data) executes the following pipeline for every incoming request:
| Step | Action | Component | Failure Behaviour |
|---|---|---|---|
| 0 | Session management — get or create a session; record the user question in conversation history | SessionService |
— |
| 1 | Fetch ontology — download the YAML ontology from {ontology_url}/yaml and parse it into an Ontology object |
FetchOntologyService |
Return ONTOLOGY_FETCH_FAILED |
| 2 | Generate SPARQL — send ontology + question to an LLM to produce a SPARQL query | QueryGenerator (currently hardcoded) |
Return QUERY_GENERATION_FAILED |
| 3 | Build sensitivity config — extract per-attribute sensitivity levels and bounds (min/max) from the ontology | Inline in orchestrator | — |
| 4 | Static evaluation (R1–R6) — parse the SPARQL algebra tree and check six privacy rules | QueryEvaluationService |
Return QUERY_REJECTED |
| 5 | Budget check — compute ε cost (epsilon_per_column × num_aggregate_columns) and verify there is enough remaining budget. The per-column ε can be overridden by the caller via the epsilon request parameter; otherwise epsilon_base is used |
PrivacyBudgetService |
Return BUDGET_EXHAUSTED |
| 6 | Deduct budget — subtract the query cost from the global budget and add it to the session's epsilon_spent |
PrivacyBudgetService / SessionService |
— |
| 7 | Execute query — run the SPARQL query against the JSON-LD data using rdflib |
QueryExecutionService |
Return QUERY_EXECUTION_FAILED |
| 8 | Add Laplace noise — inject calibrated noise into each aggregate column (COUNT, SUM, AVG) | NoiseService |
— |
| 9 | Suppress small groups — remove rows whose noisy COUNT falls below min_group_size |
NoiseService |
— |
| 10 | Wrap in NoisyResult — package the noisy rows and aggregate metadata into an immutable NoisyResult |
NoisyResult model |
— |
| 11 | Generate NL response — send the question + noisy results to an LLM; validate that numeric values were not rounded/altered; fall back to a deterministic template on failure | ResponseGenerator |
Deterministic fallback |
| 12 | Record & return — append the assistant message to conversation history and return the final JSON | Orchestrator | — |
talk-to-data-service/
├── server.py # Flask app – HTTP endpoints
├── orchestrator/
│ ├── orchestrator_service.py # Central pipeline coordinator
│ └── fetch_ontology_service.py # Ontology fetching & YAML parsing
├── query_generation/
│ ├── query_generation_service.py # SPARQL generation via LLM
│ ├── llm_client.py # Provider-agnostic LLM wrapper
│ ├── sparql_agent.py # System prompt & user message formatting
│ └── response_generator.py # NL response generation + validation
├── query_evaluation/
│ └── query_evaluation_service.py # Static privacy rule analysis (R1–R6)
├── query_execution/
│ └── query_execution_service.py # SPARQL execution on JSON-LD via rdflib
├── privacy/
│ ├── noise_service.py # Laplace noise injection + suppression
│ └── privacy_budget_service.py # Global ε budget tracking
├── models/
│ ├── ontology.py # Ontology / OntologyObject / Attribute
│ ├── noisy_result.py # NoisyResult (immutable result wrapper)
│ ├── privacy_config.py # PrivacyConfig (env-based DP settings)
│ └── session_service.py # Session / SessionService
├── tests/
│ ├── test_noise_service.py
│ ├── test_privacy_budget_service.py
│ ├── test_query_evaluation_r5_r6.py
│ ├── test_response_generator.py
│ └── test_session_service.py
├── requirements.txt
├── .env.example
└── .env # Local overrides (git-ignored)
| Endpoint | Method | Description |
|---|---|---|
/api/ping |
GET | Health check; returns "Server running" |
/api/talk-to-data |
POST | Main endpoint — accepts a question, data, ontology URL, and optional session ID |
/api/privacy-budget |
GET | Returns the current global privacy budget status |
/api/privacy-budget/reset |
POST | Resets the privacy budget (for testing/demo only) |
Request body for /api/talk-to-data:
{
"question": "What is the average salary?",
"data": { },
"ontology_url": "https://soya.ownyourdata.eu/AnonymisationDemo2",
"sessionId": "optional-uuid",
"epsilon": 0.05
}Response body:
{
"response": "The average salary is approximately 45321.78.",
"sessionId": "uuid-v4",
"remainingPrivacyBudget": 0.8,
"sessionEpsilonSpent": 0.1,
"epsilonUsed": 0.05,
"status": "success",
"data": {
"query_results": [ { "averageSalary": 45321.78 } ],
"sparql_query": "PREFIX oyd: <...> SELECT ..."
},
"conversationHistory": [
{ "role": "user", "content": "What is the average salary?" },
{ "role": "assistant", "content": "The average salary is approximately 45321.78." }
]
}Location: orchestrator/orchestrator_service.py
Central class that wires all sub-services together. Owns the global PrivacyBudgetService and SessionService. Implements the 13-step pipeline described in the workflow section.
Key methods:
| Method | Purpose |
|---|---|
talk_to_data(question, data, ontology_url, session_id, epsilon) |
Runs the full pipeline and returns the response dict. The optional epsilon overrides the per-column privacy budget for this query. |
_error_response(error_code, session_id, conversation_history) |
Builds a standardised error response with a user-safe message |
Location: orchestrator/fetch_ontology_service.py
Fetches an ontology from {url}/yaml, parses the YAML, and constructs an Ontology object. Reads sensitivity levels and min/max bounds from the OverlayClassification overlay.
Location: query_generation/query_generation_service.py
Uses LLMClient to translate a natural language question + ontology into a SPARQL query. Strips markdown code fences from LLM output.
Note: SPARQL generation is currently hardcoded in the orchestrator (see Limitations). The
QueryGeneratorclass is implemented but not yet wired up.
Location: query_generation/llm_client.py
Supports four providers via a unified call(system_prompt, user_message) → str API:
| Provider | Underlying SDK |
|---|---|
openai |
openai.OpenAI |
anthropic |
anthropic.Anthropic |
azure |
openai.AzureOpenAI |
google |
google.genai |
Configuration is read from LLM_* environment variables.
Location: query_generation/response_generator.py
Generates a human-readable answer from question + NoisyResult. Includes:
- Input sanitisation — strips control characters, truncates question to 500 chars.
- Prompt isolation — user question is wrapped in
<user_question>tags. - Output validation — every number in the LLM response is checked against the actual noisy values. If the LLM rounded or hallucinated a number, the response is rejected and a deterministic fallback template is used.
Location: query_evaluation/query_evaluation_service.py
Parses SPARQL queries into an algebra tree (via rdflib) and enforces six privacy rules. Returns (is_valid, message, aggregate_info).
Aggregate metadata (aggregate_info) is passed downstream to NoiseService so noise can be calibrated per-column.
Location: query_execution/query_execution_service.py
Loads JSON-LD data into an rdflib.Graph, runs the SPARQL query, and converts RDF terms to native Python types (preserving int/float/datetime).
Location: privacy/noise_service.py
| Method | Sensitivity (Δf) | Scale |
|---|---|---|
_add_count_noise |
1 | 1 / ε |
_add_sum_noise |
max − min |
(max − min) / ε |
_add_avg_noise_clipped_mean |
max − min (clipped mean) |
(max − min) / (ε / 2) |
suppress_small_groups |
— | Removes rows with noisy count < min_group_size |
Uses numpy for random number generation with an optional seed for reproducible tests.
Location: privacy/privacy_budget_service.py
Tracks a global ε budget (shared across all sessions). Key operations:
calculate_query_cost(num_aggregate_columns, epsilon_override=None)→epsilon × num_columns(usesepsilon_overrideif provided, otherwiseepsilon_base)check_budget(epsilon_query)→Trueif enough budget remainsdeduct_budget(epsilon_query)→ subtracts from remaining budgetreset()→ resets spent budget (for testing only)
Location: models/ontology.py
Ontology
├── prefix: str (e.g. "oyd")
├── base_uri: str (e.g. "https://soya.ownyourdata.eu/AnonymisationDemo2/")
└── objects: List[OntologyObject]
└── OntologyObject
├── name: str
└── attributes: List[Attribute]
└── Attribute
├── name: str
├── anonymization_type: str
├── sensitivity_level: str ("sensitive" | "semi-sensitive" | "not-sensitive")
├── min_value: Optional[float]
└── max_value: Optional[float]
Location: models/noisy_result.py
Immutable (frozen=True) dataclass wrapping post-noise query results. Enforces at the type level that only noised data reaches the response layer.
Location: models/privacy_config.py
Loaded from environment variables. Fields: epsilon_total, epsilon_base, min_group_size, max_semi_sensitive_group_by.
Location: models/session_service.py
In-memory session store keyed by UUIDv4. Each session tracks conversation history and per-session epsilon_spent.
| Variable | Default | Description |
|---|---|---|
LLM_PROVIDER |
openai |
LLM provider (openai, anthropic, azure, google) |
LLM_API_KEY |
— | API key for the LLM provider |
LLM_MODEL |
gpt-4o |
Model identifier |
RESPONSE_LLM_PROVIDER |
(falls back to LLM_PROVIDER) |
Override provider for NL response generation |
RESPONSE_LLM_MODEL |
(falls back to LLM_MODEL) |
Override model for NL response generation |
RESPONSE_LLM_API_KEY |
(falls back to LLM_API_KEY) |
Override API key for NL response generation |
EPSILON_TOTAL |
1.0 |
Total global privacy budget |
EPSILON_BASE |
0.1 |
Default ε cost per aggregate column in a query (used when the caller does not provide an explicit epsilon) |
MIN_GROUP_SIZE |
5 |
Minimum noisy count to keep a group (suppression threshold) |
MAX_SEMI_SENSITIVE_GROUP_BY |
1 |
Max semi-sensitive attributes allowed in GROUP BY |
AZURE_OPENAI_ENDPOINT |
— | Required when LLM_PROVIDER=azure |
AZURE_OPENAI_API_VERSION |
2024-02-15-preview |
API version for Azure OpenAI |
The main endpoint. Accepts question, data (JSON-LD), ontology_url, optional sessionId, and optional epsilon (per-column privacy budget override). Returns the noisy answer, session metadata, and remaining budget.
Returns remaining_budget, epsilon_total, and epsilon_base.
Resets the global budget to epsilon_total. Intended for testing/demo only.
Health check.
These rules are enforced by QueryEvaluationService through static analysis of the SPARQL algebra tree before execution:
| Rule | Description |
|---|---|
| R1 | Sensitive attributes must not appear anywhere in the query (SELECT, WHERE, FILTER). |
| R2 | Semi-sensitive attributes must not appear in FILTER expressions or be constrained by concrete literal values in WHERE triple patterns (e.g. ?s oyd:alter 30 is treated the same as FILTER(?alter = 30)). |
| R3 | Semi-sensitive attributes in SELECT must be wrapped in an aggregate function (COUNT, AVG, SUM, etc.). |
| R4 | At most MAX_SEMI_SENSITIVE_GROUP_BY semi-sensitive attributes may appear in GROUP BY (prevents quasi-identifier creation). |
| R5 | MIN, MAX, SAMPLE, and GROUP_CONCAT are blocked on semi-sensitive attributes (these aggregates can reveal individual values). |
| R6 | SUM and AVG on semi-sensitive attributes require min/max bounds in the ontology overlay (needed to calibrate sensitivity for the Laplace mechanism). |
| R7 | Queries must not use concrete subject URIs (specific individuals) in triple patterns that access sensitive or semi-sensitive predicates. This prevents direct lookup of an individual's protected data. |
| R8 | Queries using AVG or SUM on semi-sensitive attributes must also include a COUNT aggregate. This enables small-group suppression to protect against quasi-identifier attacks via population narrowing. |
- In-memory state: The privacy budget and session store are held in memory. A server restart resets both. There is no persistence layer.
- Hardcoded SPARQL query: The SPARQL generation step is currently hardcoded in the orchestrator (
orchestrator_service.pystep 2). TheQueryGeneratorclass exists but is not wired into the pipeline. - Single-provider LLM: Only one LLM provider is active at a time. There is no retry/fallback across providers.
- Global budget only: The privacy budget is global (shared across all sessions). There is no per-user or per-dataset budget isolation.
- No authentication/authorisation: The API has no auth layer. Anyone with network access can query the service and consume the privacy budget.
- No rate limiting: There is no throttle on API requests.
- YAML-only ontology format: The ontology must be served in the specific SOYA YAML format. No JSON-LD or Turtle ontology input is supported.
- Wire up
QueryGenerator— replace the hardcoded SPARQL query in the orchestrator with actual LLM-based generation. - Persistent budget storage — use a database or file-backed store so the budget survives server restarts.
- Per-user / per-dataset budget isolation — allow scoped budgets instead of a single global one.
- Authentication & authorisation — add API key or OAuth-based access control.
- Rate limiting — prevent budget exhaustion through rapid-fire requests.
- Multi-turn SPARQL generation — leverage conversation history to refine queries across turns.
- Support additional ontology formats — accept JSON-LD or Turtle ontology definitions directly.
- Improve AVG noise mechanism — the clipped-mean implementation currently adds noise directly to the average without a true noisy-sum / noisy-count division (the count information is not available at noise injection time).
- Frontend / chatbot UI — build a web interface for the chatbot (the response shape already supports it).
- Logging & monitoring — add structured logging, metrics export, and request tracing.
- Containerisation — provide a
Dockerfileanddocker-compose.ymlfor deployment.