Commit ·
efc0c0a
1
Parent(s): 8557a20
initial structure
Browse files- scripts/build_initial_catalogs.py +16 -0
- scripts/enrich_all_sources.py +16 -0
- src/agents/intent_router.py +24 -0
- src/catalog/enricher.py +16 -0
- src/catalog/introspect/base.py +17 -0
- src/catalog/introspect/database.py +16 -0
- src/catalog/introspect/tabular.py +15 -0
- src/catalog/models.py +55 -0
- src/catalog/pii_detector.py +16 -0
- src/catalog/reader.py +23 -0
- src/catalog/store.py +20 -0
- src/catalog/validator.py +24 -0
- src/config/prompts/__init__.py +0 -0
- src/config/prompts/catalog_enricher.md +21 -0
- src/config/prompts/chatbot_system.md +17 -0
- src/config/prompts/guardrails.md +12 -0
- src/config/prompts/intent_router.md +25 -0
- src/config/prompts/query_planner.md +25 -0
- src/models/api/__init__.py +1 -0
- src/models/api/catalog.py +14 -0
- src/models/api/chat.py +17 -0
- src/models/api/document.py +9 -0
- src/pipeline/document_pipeline.py +11 -0
- src/pipeline/orchestrator.py +11 -0
- src/pipeline/structured_pipeline.py +13 -0
- src/pipeline/triggers.py +21 -0
- src/query/compiler/base.py +13 -0
- src/query/compiler/pandas.py +22 -0
- src/query/compiler/sql.py +28 -0
- src/query/executor/base.py +26 -0
- src/query/executor/db.py +25 -0
- src/query/executor/dispatcher.py +17 -0
- src/query/executor/tabular.py +24 -0
- src/query/ir/models.py +60 -0
- src/query/ir/operators.py +17 -0
- src/query/ir/validator.py +28 -0
- src/query/planner/prompt.py +12 -0
- src/query/planner/service.py +15 -0
- src/query/service.py +15 -0
- src/retrieval/document.py +15 -0
- src/retrieval/router.py +11 -0
- src/security/auth.py +21 -0
- src/security/credentials.py +15 -0
- src/security/pii_patterns.py +20 -0
scripts/build_initial_catalogs.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backfill catalogs for existing users.
|
| 2 |
+
|
| 3 |
+
One-off script. For each user that already has registered DB connections or
|
| 4 |
+
uploaded tabular files, run the structured pipeline to build their catalog.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
uv run python scripts/build_initial_catalogs.py [--user-id USER_ID]
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def main() -> None:
|
| 12 |
+
raise NotImplementedError
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
if __name__ == "__main__":
|
| 16 |
+
main()
|
scripts/enrich_all_sources.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bulk re-run CatalogEnricher with the current prompt.
|
| 2 |
+
|
| 3 |
+
For when src/config/prompts/catalog_enricher.md changes and existing
|
| 4 |
+
catalog descriptions need to be regenerated across all users.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
uv run python scripts/enrich_all_sources.py [--user-id USER_ID]
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def main() -> None:
|
| 12 |
+
raise NotImplementedError
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
if __name__ == "__main__":
|
| 16 |
+
main()
|
src/agents/intent_router.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""IntentRouter — classifies a user message and emits source_hint.
|
| 2 |
+
|
| 3 |
+
Output: needs_search (bool) + source_hint ∈ { chat, unstructured, structured }.
|
| 4 |
+
|
| 5 |
+
Replaces the previous orchestration.py once the chat endpoint is rewired.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Literal
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
SourceHint = Literal["chat", "unstructured", "structured"]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class IntentRouterDecision:
|
| 17 |
+
needs_search: bool
|
| 18 |
+
source_hint: SourceHint
|
| 19 |
+
rewritten_query: str | None = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class IntentRouter:
|
| 23 |
+
async def classify(self, message: str) -> IntentRouterDecision:
|
| 24 |
+
raise NotImplementedError
|
src/catalog/enricher.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CatalogEnricher — runs 1 LLM call per source to generate AI descriptions.
|
| 2 |
+
|
| 3 |
+
Input: a draft Source produced by an introspector (raw schema, no descriptions).
|
| 4 |
+
Output: the same Source enriched with description fields at source/table/column level.
|
| 5 |
+
|
| 6 |
+
Prompt: src/config/prompts/catalog_enricher.md
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from .models import Source
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class CatalogEnricher:
|
| 13 |
+
"""Adds AI-generated descriptions to a freshly introspected source."""
|
| 14 |
+
|
| 15 |
+
async def enrich(self, source: Source) -> Source:
|
| 16 |
+
raise NotImplementedError
|
src/catalog/introspect/base.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BaseIntrospector — contract for source-specific schema readers.
|
| 2 |
+
|
| 3 |
+
Subclasses produce a draft Source object with raw schema (names + types +
|
| 4 |
+
sample values). The CatalogEnricher then adds descriptions in a separate step.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from abc import ABC, abstractmethod
|
| 8 |
+
|
| 9 |
+
from ..models import Source
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class BaseIntrospector(ABC):
|
| 13 |
+
"""Abstract base. Subclasses: DatabaseIntrospector, TabularIntrospector."""
|
| 14 |
+
|
| 15 |
+
@abstractmethod
|
| 16 |
+
async def introspect(self, location_ref: str) -> Source:
|
| 17 |
+
...
|
src/catalog/introspect/database.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database schema introspection (Postgres / MySQL / Supabase).
|
| 2 |
+
|
| 3 |
+
Reads information_schema for tables/columns/types, samples ~100 rows per table
|
| 4 |
+
for `sample_values` and basic stats. Does NOT generate descriptions
|
| 5 |
+
(that happens in CatalogEnricher).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from ..models import Source
|
| 9 |
+
from .base import BaseIntrospector
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class DatabaseIntrospector(BaseIntrospector):
|
| 13 |
+
"""Connect to user DB → read information_schema → sample 100 rows/table."""
|
| 14 |
+
|
| 15 |
+
async def introspect(self, location_ref: str) -> Source:
|
| 16 |
+
raise NotImplementedError
|
src/catalog/introspect/tabular.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tabular file schema introspection (Parquet / CSV / XLSX).
|
| 2 |
+
|
| 3 |
+
Reads file headers + samples ~100 rows. For XLSX, each sheet becomes a Table.
|
| 4 |
+
Files are expected to live in Azure Blob (location_ref like az_blob://...).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from ..models import Source
|
| 8 |
+
from .base import BaseIntrospector
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TabularIntrospector(BaseIntrospector):
|
| 12 |
+
"""Read column names, dtypes, and sample values from Parquet/CSV/XLSX."""
|
| 13 |
+
|
| 14 |
+
async def introspect(self, location_ref: str) -> Source:
|
| 15 |
+
raise NotImplementedError
|
src/catalog/models.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic models for the per-user data catalog (Cs + Ct).
|
| 2 |
+
|
| 3 |
+
See ARCHITECTURE.md §6 for the full schema definition.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Any, Literal
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel, Field
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
SourceType = Literal["schema", "tabular", "unstructured"]
|
| 13 |
+
DataType = Literal["int", "decimal", "string", "datetime", "date", "bool", "json"]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ColumnStats(BaseModel):
|
| 17 |
+
min: Any | None = None
|
| 18 |
+
max: Any | None = None
|
| 19 |
+
distinct_count: int | None = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Column(BaseModel):
|
| 23 |
+
column_id: str
|
| 24 |
+
name: str
|
| 25 |
+
data_type: DataType
|
| 26 |
+
description: str
|
| 27 |
+
nullable: bool
|
| 28 |
+
pii_flag: bool = False
|
| 29 |
+
sample_values: list[Any] | None = None
|
| 30 |
+
stats: ColumnStats | None = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class Table(BaseModel):
|
| 34 |
+
table_id: str
|
| 35 |
+
name: str
|
| 36 |
+
description: str
|
| 37 |
+
row_count: int | None = None
|
| 38 |
+
columns: list[Column]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class Source(BaseModel):
|
| 42 |
+
source_id: str
|
| 43 |
+
source_type: SourceType
|
| 44 |
+
name: str
|
| 45 |
+
description: str
|
| 46 |
+
location_ref: str
|
| 47 |
+
updated_at: datetime
|
| 48 |
+
tables: list[Table] = Field(default_factory=list)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class Catalog(BaseModel):
|
| 52 |
+
user_id: str
|
| 53 |
+
schema_version: str = "1.0"
|
| 54 |
+
generated_at: datetime
|
| 55 |
+
sources: list[Source] = Field(default_factory=list)
|
src/catalog/pii_detector.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PII auto-detection for catalog columns.
|
| 2 |
+
|
| 3 |
+
When pii_flag is set True, sample_values is forced to None so real PII
|
| 4 |
+
never enters LLM prompts.
|
| 5 |
+
|
| 6 |
+
Patterns live in src/security/pii_patterns.py.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from .models import Column
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class PIIDetector:
|
| 13 |
+
"""Marks columns as pii_flag=True when name/values look sensitive."""
|
| 14 |
+
|
| 15 |
+
def detect(self, column: Column) -> bool:
|
| 16 |
+
raise NotImplementedError
|
src/catalog/reader.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CatalogReader — loads + filters catalog by source_hint.
|
| 2 |
+
|
| 3 |
+
For typical users (≤50 tables), returns the FULL catalog with no slicing.
|
| 4 |
+
Catalog-level search is added later if catalog grows past the limit.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Literal
|
| 8 |
+
|
| 9 |
+
from .models import Catalog
|
| 10 |
+
from .store import CatalogStore
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
SourceHint = Literal["chat", "unstructured", "structured"]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class CatalogReader:
|
| 17 |
+
"""Loads the user's catalog and filters by source_hint."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, store: CatalogStore) -> None:
|
| 20 |
+
self._store = store
|
| 21 |
+
|
| 22 |
+
async def read(self, user_id: str, source_hint: SourceHint) -> Catalog:
|
| 23 |
+
raise NotImplementedError
|
src/catalog/store.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CatalogStore — persists per-user catalogs as Postgres jsonb rows.
|
| 2 |
+
|
| 3 |
+
Storage shape: one row per user in a `catalogs` table with columns
|
| 4 |
+
(user_id PK, data jsonb, schema_version, generated_at, updated_at).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .models import Catalog
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class CatalogStore:
|
| 11 |
+
"""Read/write catalogs keyed by user_id."""
|
| 12 |
+
|
| 13 |
+
async def get(self, user_id: str) -> Catalog | None:
|
| 14 |
+
raise NotImplementedError
|
| 15 |
+
|
| 16 |
+
async def upsert(self, catalog: Catalog) -> None:
|
| 17 |
+
raise NotImplementedError
|
| 18 |
+
|
| 19 |
+
async def delete(self, user_id: str) -> None:
|
| 20 |
+
raise NotImplementedError
|
src/catalog/validator.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CatalogValidator — Pydantic + business-rule validation for a catalog.
|
| 2 |
+
|
| 3 |
+
Pydantic handles shape; this layer adds invariants that span fields.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from .models import Catalog
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class CatalogValidationError(Exception):
|
| 10 |
+
pass
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class CatalogValidator:
|
| 14 |
+
"""Validates a Catalog beyond Pydantic schema checks.
|
| 15 |
+
|
| 16 |
+
Business rules:
|
| 17 |
+
- All source_ids unique within a user
|
| 18 |
+
- All table_ids unique within a source
|
| 19 |
+
- All column_ids unique within a table
|
| 20 |
+
- foreign_keys (when added) reference existing tables/columns
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
def validate(self, catalog: Catalog) -> None:
|
| 24 |
+
raise NotImplementedError
|
src/config/prompts/__init__.py
ADDED
|
File without changes
|
src/config/prompts/catalog_enricher.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Catalog Enricher Prompt
|
| 2 |
+
|
| 3 |
+
Takes raw introspected schema (table names, column names, types, sample values)
|
| 4 |
+
and produces AI-generated descriptions for the source, each table, and each column.
|
| 5 |
+
|
| 6 |
+
One LLM call per source at ingestion time. Used by `src/catalog/enricher.py`.
|
| 7 |
+
|
| 8 |
+
## System prompt
|
| 9 |
+
|
| 10 |
+
(to be written)
|
| 11 |
+
|
| 12 |
+
## Output schema
|
| 13 |
+
|
| 14 |
+
Strict JSON matching the catalog Pydantic models in `src/catalog/models.py`.
|
| 15 |
+
Validated by `CatalogValidator` before being persisted.
|
| 16 |
+
|
| 17 |
+
## Notes
|
| 18 |
+
|
| 19 |
+
- Descriptions should be one-line and factual.
|
| 20 |
+
- Do NOT invent column meanings beyond what the sample values support.
|
| 21 |
+
- For `pii_flag` columns, do NOT include sample values in the description.
|
src/config/prompts/chatbot_system.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Chatbot System Prompt
|
| 2 |
+
|
| 3 |
+
Final answer-formation step. Receives the user question, retrieval context (Cu)
|
| 4 |
+
or query results (from QueryExecutor), and produces the natural-language answer.
|
| 5 |
+
|
| 6 |
+
Used by `src/agents/chatbot.py`. SSE-streamed back to the user.
|
| 7 |
+
|
| 8 |
+
## System prompt
|
| 9 |
+
|
| 10 |
+
(content to be ported from src/config/agents/system_prompt.md, then rewritten
|
| 11 |
+
to remove references to the old retrieval pipeline and reflect catalog-driven flow)
|
| 12 |
+
|
| 13 |
+
## Notes
|
| 14 |
+
|
| 15 |
+
- Keep answers grounded in the provided context — no hallucination.
|
| 16 |
+
- For tabular results, format numbers with appropriate units (from `column.unit` when available).
|
| 17 |
+
- Cite sources when applicable (filename + page label).
|
src/config/prompts/guardrails.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Guardrails
|
| 2 |
+
|
| 3 |
+
Safety / refusal / scope-bounding rules applied to all LLM calls.
|
| 4 |
+
|
| 5 |
+
(content to be ported from src/config/agents/guardrails_prompt.md)
|
| 6 |
+
|
| 7 |
+
## Scope
|
| 8 |
+
|
| 9 |
+
- Refuse PII extraction requests
|
| 10 |
+
- Refuse questions outside the user's data scope
|
| 11 |
+
- Refuse code-execution / shell-style requests
|
| 12 |
+
- (more to be added)
|
src/config/prompts/intent_router.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Intent Router Prompt
|
| 2 |
+
|
| 3 |
+
Classifies a user message into:
|
| 4 |
+
- `needs_search`: bool
|
| 5 |
+
- `source_hint`: `chat` | `unstructured` | `structured`
|
| 6 |
+
|
| 7 |
+
Used by `src/agents/intent_router.py`.
|
| 8 |
+
|
| 9 |
+
## System prompt
|
| 10 |
+
|
| 11 |
+
(to be written)
|
| 12 |
+
|
| 13 |
+
## Output schema
|
| 14 |
+
|
| 15 |
+
```json
|
| 16 |
+
{
|
| 17 |
+
"needs_search": true,
|
| 18 |
+
"source_hint": "structured",
|
| 19 |
+
"rewritten_query": "..."
|
| 20 |
+
}
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
## Few-shot examples
|
| 24 |
+
|
| 25 |
+
(to be written)
|
src/config/prompts/query_planner.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Query Planner Prompt
|
| 2 |
+
|
| 3 |
+
Takes a user question + their data catalog (Cs ∪ Ct).
|
| 4 |
+
Produces a JSON IR that describes the query intent.
|
| 5 |
+
|
| 6 |
+
See ARCHITECTURE.md §7 for the IR schema. Used by `src/query/planner/service.py`.
|
| 7 |
+
|
| 8 |
+
## System prompt
|
| 9 |
+
|
| 10 |
+
(to be written)
|
| 11 |
+
|
| 12 |
+
## Output schema
|
| 13 |
+
|
| 14 |
+
Strict JSON matching `src/query/ir/models.py:QueryIR`.
|
| 15 |
+
Validated by `IRValidator` against the catalog before reaching the compiler.
|
| 16 |
+
|
| 17 |
+
## Few-shot examples
|
| 18 |
+
|
| 19 |
+
(to be written — 5–10 examples covering filter + groupby + agg + sort + limit)
|
| 20 |
+
|
| 21 |
+
## Notes
|
| 22 |
+
|
| 23 |
+
- Reference columns by `column_id`, not `name`.
|
| 24 |
+
- `value_type` must match the column's `data_type`.
|
| 25 |
+
- Only emit operators/aggs from the whitelist (`src/query/ir/operators.py`).
|
src/models/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API request/response shapes per route family."""
|
src/models/api/catalog.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Request / response models for catalog-related routes (e.g. /knowledge/rebuild)."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class CatalogRebuildRequest(BaseModel):
|
| 7 |
+
user_id: str
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class CatalogRebuildResponse(BaseModel):
|
| 11 |
+
user_id: str
|
| 12 |
+
sources_enriched: int
|
| 13 |
+
tables_enriched: int
|
| 14 |
+
columns_enriched: int
|
src/models/api/chat.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Request / response models for /api/v1/chat/* routes."""
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ChatRequest(BaseModel):
|
| 9 |
+
user_id: str
|
| 10 |
+
room_id: str
|
| 11 |
+
message: str
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ChatStreamEvent(BaseModel):
|
| 15 |
+
"""One SSE event. Type values: `sources`, `chunk`, `done`."""
|
| 16 |
+
event: str
|
| 17 |
+
data: dict[str, Any]
|
src/models/api/document.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Request / response models for /api/v1/documents/* routes."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class DocumentUploadResponse(BaseModel):
|
| 7 |
+
document_id: str
|
| 8 |
+
filename: str
|
| 9 |
+
status: str # uploaded | processing | completed | failed
|
src/pipeline/document_pipeline.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DocumentPipeline — extract text, chunk, embed, ingest to PGVector.
|
| 2 |
+
|
| 3 |
+
For unstructured sources (PDF / DOCX / TXT). Receives the working
|
| 4 |
+
implementation from the previous pipeline/document_pipeline/document_pipeline.py
|
| 5 |
+
during the cleanup phase.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class DocumentPipeline:
|
| 10 |
+
async def run(self, document_id: str, user_id: str) -> None:
|
| 11 |
+
raise NotImplementedError
|
src/pipeline/orchestrator.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""IngestionOrchestrator — top-level coordinator for ingestion.
|
| 2 |
+
|
| 3 |
+
Routes uploads / DB connections to the right pipeline:
|
| 4 |
+
- unstructured (pdf/docx/txt) → DocumentPipeline
|
| 5 |
+
- schema or tabular → StructuredPipeline (which writes to the catalog)
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class IngestionOrchestrator:
|
| 10 |
+
async def ingest(self, source_ref: str, source_type: str, user_id: str) -> None:
|
| 11 |
+
raise NotImplementedError
|
src/pipeline/structured_pipeline.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""StructuredPipeline — runs catalog enrichment for DB / tabular sources.
|
| 2 |
+
|
| 3 |
+
Steps:
|
| 4 |
+
1. introspect (catalog/introspect/database.py or catalog/introspect/tabular.py)
|
| 5 |
+
2. enrich (catalog/enricher.py — 1 LLM call per source)
|
| 6 |
+
3. validate (catalog/validator.py)
|
| 7 |
+
4. write (catalog/store.py)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class StructuredPipeline:
|
| 12 |
+
async def run(self, source_ref: str, source_type: str, user_id: str) -> None:
|
| 13 |
+
raise NotImplementedError
|
src/pipeline/triggers.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pipeline trigger entry points called from API routes / event handlers.
|
| 2 |
+
|
| 3 |
+
These thin functions are what the FastAPI routes invoke; they delegate
|
| 4 |
+
to IngestionOrchestrator.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def on_document_uploaded(document_id: str, user_id: str) -> None:
|
| 9 |
+
raise NotImplementedError
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def on_db_registered(database_client_id: str, user_id: str) -> None:
|
| 13 |
+
raise NotImplementedError
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def on_tabular_uploaded(document_id: str, user_id: str) -> None:
|
| 17 |
+
raise NotImplementedError
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def on_catalog_rebuild_requested(user_id: str) -> None:
|
| 21 |
+
raise NotImplementedError
|
src/query/compiler/base.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BaseCompiler — contract for IR → executable shape (SQL string or pandas chain)."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
|
| 5 |
+
from ..ir.models import QueryIR
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class BaseCompiler(ABC):
|
| 9 |
+
"""Subclasses: SqlCompiler, PandasCompiler."""
|
| 10 |
+
|
| 11 |
+
@abstractmethod
|
| 12 |
+
def compile(self, ir: QueryIR) -> object:
|
| 13 |
+
...
|
src/query/compiler/pandas.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PandasCompiler — IR → callable that runs against a DataFrame.
|
| 2 |
+
|
| 3 |
+
For tabular sources. The callable encapsulates the chain of operations
|
| 4 |
+
(filter → groupby → agg → sort → limit) so the executor can apply them
|
| 5 |
+
to a DataFrame loaded eagerly or via predicate pushdown / polars lazy scan.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Callable
|
| 9 |
+
|
| 10 |
+
from ...catalog.models import Catalog
|
| 11 |
+
from ..ir.models import QueryIR
|
| 12 |
+
from .base import BaseCompiler
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class PandasCompiler(BaseCompiler):
|
| 16 |
+
"""Deterministic IR → pandas/polars op chain. No LLM."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, catalog: Catalog) -> None:
|
| 19 |
+
self._catalog = catalog
|
| 20 |
+
|
| 21 |
+
def compile(self, ir: QueryIR) -> Callable:
|
| 22 |
+
raise NotImplementedError
|
src/query/compiler/sql.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SqlCompiler — IR → (SQL string, parameters list).
|
| 2 |
+
|
| 3 |
+
Identifiers (table, column names) come from the catalog (trusted).
|
| 4 |
+
Values come from IR.filters and are ALWAYS parameterized — never inlined.
|
| 5 |
+
Output is validated by sqlglot before reaching the executor.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
|
| 10 |
+
from ...catalog.models import Catalog
|
| 11 |
+
from ..ir.models import QueryIR
|
| 12 |
+
from .base import BaseCompiler
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class CompiledSql:
|
| 17 |
+
sql: str
|
| 18 |
+
params: list[object]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class SqlCompiler(BaseCompiler):
|
| 22 |
+
"""Deterministic IR → SQL. No LLM."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, catalog: Catalog) -> None:
|
| 25 |
+
self._catalog = catalog
|
| 26 |
+
|
| 27 |
+
def compile(self, ir: QueryIR) -> CompiledSql:
|
| 28 |
+
raise NotImplementedError
|
src/query/executor/base.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BaseExecutor + QueryResult — uniform return shape across DB and tabular paths."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from ..ir.models import QueryIR
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class QueryResult:
|
| 12 |
+
source_id: str
|
| 13 |
+
backend: str # "sql" | "tabular"
|
| 14 |
+
rows: list[dict[str, Any]] = field(default_factory=list)
|
| 15 |
+
row_count: int = 0
|
| 16 |
+
truncated: bool = False
|
| 17 |
+
elapsed_ms: int = 0
|
| 18 |
+
error: str | None = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class BaseExecutor(ABC):
|
| 22 |
+
"""Subclasses: DbExecutor, TabularExecutor."""
|
| 23 |
+
|
| 24 |
+
@abstractmethod
|
| 25 |
+
async def run(self, ir: QueryIR) -> QueryResult:
|
| 26 |
+
...
|
src/query/executor/db.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DbExecutor — runs compiled SQL on a user's external DB.
|
| 2 |
+
|
| 3 |
+
Pipeline:
|
| 4 |
+
IR → SqlCompiler → SQL string + params
|
| 5 |
+
↓
|
| 6 |
+
sqlglot validation (SELECT-only, whitelist tables/columns, LIMIT enforced)
|
| 7 |
+
↓
|
| 8 |
+
asyncpg / pymysql in read-only transaction with timeout (30s)
|
| 9 |
+
↓
|
| 10 |
+
QueryResult
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from ...catalog.models import Catalog
|
| 14 |
+
from ..compiler.sql import SqlCompiler
|
| 15 |
+
from ..ir.models import QueryIR
|
| 16 |
+
from .base import BaseExecutor, QueryResult
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class DbExecutor(BaseExecutor):
|
| 20 |
+
def __init__(self, catalog: Catalog) -> None:
|
| 21 |
+
self._catalog = catalog
|
| 22 |
+
self._compiler = SqlCompiler(catalog)
|
| 23 |
+
|
| 24 |
+
async def run(self, ir: QueryIR) -> QueryResult:
|
| 25 |
+
raise NotImplementedError
|
src/query/executor/dispatcher.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Picks DB vs Tabular executor based on the source_type of the IR's source.
|
| 2 |
+
|
| 3 |
+
This is the only place in the structured query path where the schema/tabular
|
| 4 |
+
distinction matters. Every step before this is source-type-agnostic.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from ...catalog.models import Catalog
|
| 8 |
+
from ..ir.models import QueryIR
|
| 9 |
+
from .base import BaseExecutor
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ExecutorDispatcher:
|
| 13 |
+
def __init__(self, catalog: Catalog) -> None:
|
| 14 |
+
self._catalog = catalog
|
| 15 |
+
|
| 16 |
+
def pick(self, ir: QueryIR) -> BaseExecutor:
|
| 17 |
+
raise NotImplementedError
|
src/query/executor/tabular.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TabularExecutor — runs compiled pandas/polars chain on a Parquet file.
|
| 2 |
+
|
| 3 |
+
Picks engine by file size:
|
| 4 |
+
≤ 100 MB → eager pandas
|
| 5 |
+
100 MB-1 GB → pyarrow with predicate pushdown
|
| 6 |
+
> 1 GB → polars lazy scan
|
| 7 |
+
|
| 8 |
+
Initial scope ships eager pandas only; the others are added when a real
|
| 9 |
+
file is too big.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from ...catalog.models import Catalog
|
| 13 |
+
from ..compiler.pandas import PandasCompiler
|
| 14 |
+
from ..ir.models import QueryIR
|
| 15 |
+
from .base import BaseExecutor, QueryResult
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TabularExecutor(BaseExecutor):
|
| 19 |
+
def __init__(self, catalog: Catalog) -> None:
|
| 20 |
+
self._catalog = catalog
|
| 21 |
+
self._compiler = PandasCompiler(catalog)
|
| 22 |
+
|
| 23 |
+
async def run(self, ir: QueryIR) -> QueryResult:
|
| 24 |
+
raise NotImplementedError
|
src/query/ir/models.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JSON IR (intermediate representation) Pydantic models.
|
| 2 |
+
|
| 3 |
+
See ARCHITECTURE.md §7 for the schema.
|
| 4 |
+
|
| 5 |
+
Initial scope: single-table; filter, group_by, agg, order_by, limit.
|
| 6 |
+
Joins, having, offset, boolean tree filters are deferred to later versions.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Any, Literal
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
FilterOp = Literal[
|
| 15 |
+
"=", "!=", "<", "<=", ">", ">=",
|
| 16 |
+
"in", "not_in", "is_null", "is_not_null",
|
| 17 |
+
"like", "between",
|
| 18 |
+
]
|
| 19 |
+
AggFn = Literal["count", "count_distinct", "sum", "avg", "min", "max"]
|
| 20 |
+
ValueType = Literal["int", "decimal", "string", "datetime", "date", "bool"]
|
| 21 |
+
SortDir = Literal["asc", "desc"]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ColumnSelect(BaseModel):
|
| 25 |
+
kind: Literal["column"] = "column"
|
| 26 |
+
column_id: str
|
| 27 |
+
alias: str | None = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class AggSelect(BaseModel):
|
| 31 |
+
kind: Literal["agg"] = "agg"
|
| 32 |
+
fn: AggFn
|
| 33 |
+
column_id: str | None = None
|
| 34 |
+
alias: str | None = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
SelectItem = ColumnSelect | AggSelect
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class FilterClause(BaseModel):
|
| 41 |
+
column_id: str
|
| 42 |
+
op: FilterOp
|
| 43 |
+
value: Any
|
| 44 |
+
value_type: ValueType
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class OrderByClause(BaseModel):
|
| 48 |
+
column_id: str
|
| 49 |
+
dir: SortDir = "asc"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class QueryIR(BaseModel):
|
| 53 |
+
ir_version: str = "1.0"
|
| 54 |
+
source_id: str
|
| 55 |
+
table_id: str
|
| 56 |
+
select: list[SelectItem]
|
| 57 |
+
filters: list[FilterClause] = Field(default_factory=list)
|
| 58 |
+
group_by: list[str] = Field(default_factory=list)
|
| 59 |
+
order_by: list[OrderByClause] = Field(default_factory=list)
|
| 60 |
+
limit: int | None = None
|
src/query/ir/operators.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Whitelisted operators + aggregation functions for IR validation."""
|
| 2 |
+
|
| 3 |
+
ALLOWED_FILTER_OPS = frozenset({
|
| 4 |
+
"=", "!=", "<", "<=", ">", ">=",
|
| 5 |
+
"in", "not_in", "is_null", "is_not_null",
|
| 6 |
+
"like", "between",
|
| 7 |
+
})
|
| 8 |
+
|
| 9 |
+
ALLOWED_AGG_FNS = frozenset({
|
| 10 |
+
"count", "count_distinct", "sum", "avg", "min", "max",
|
| 11 |
+
})
|
| 12 |
+
|
| 13 |
+
LIMIT_HARD_CAP = 10_000
|
| 14 |
+
|
| 15 |
+
# Type compatibility: which value_types are valid for each column data_type.
|
| 16 |
+
# To be filled with the explicit matrix when validator.py is implemented.
|
| 17 |
+
TYPE_COMPATIBILITY: dict[str, frozenset[str]] = {}
|
src/query/ir/validator.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""IRValidator — checks a QueryIR against a user's catalog.
|
| 2 |
+
|
| 3 |
+
See ARCHITECTURE.md §7 for the validation rules.
|
| 4 |
+
On failure, the planner is re-prompted with the error context (max 3 retries).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from ...catalog.models import Catalog
|
| 8 |
+
from .models import QueryIR
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class IRValidationError(Exception):
|
| 12 |
+
pass
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class IRValidator:
|
| 16 |
+
"""Reject IRs that reference unknown sources/tables/columns or use disallowed ops.
|
| 17 |
+
|
| 18 |
+
Rules:
|
| 19 |
+
- source_id exists in catalog for this user
|
| 20 |
+
- table_id belongs to that source
|
| 21 |
+
- every column_id exists in that table
|
| 22 |
+
- every agg.fn and filter.op is whitelisted (see operators.py)
|
| 23 |
+
- value_type consistent with column.data_type
|
| 24 |
+
- limit positive int, ≤ hard cap
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def validate(self, ir: QueryIR, catalog: Catalog) -> None:
|
| 28 |
+
raise NotImplementedError
|
src/query/planner/prompt.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Builds the planner LLM prompt from question + catalog.
|
| 2 |
+
|
| 3 |
+
Renders the catalog into a compact textual form that fits the LLM context
|
| 4 |
+
window. For users with ≤50 tables the full catalog goes in verbatim.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from ...catalog.models import Catalog
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def build_planner_prompt(question: str, catalog: Catalog) -> str:
|
| 11 |
+
"""Return the full prompt string to feed the planner LLM."""
|
| 12 |
+
raise NotImplementedError
|
src/query/planner/service.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""QueryPlannerService — single LLM call: question + catalog → JSON IR.
|
| 2 |
+
|
| 3 |
+
Prompt: src/config/prompts/query_planner.md
|
| 4 |
+
Output: a QueryIR ready for the IRValidator.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from ...catalog.models import Catalog
|
| 8 |
+
from ..ir.models import QueryIR
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class QueryPlannerService:
|
| 12 |
+
"""Wraps the LLM call with structured-output parsing into QueryIR."""
|
| 13 |
+
|
| 14 |
+
async def plan(self, question: str, catalog: Catalog) -> QueryIR:
|
| 15 |
+
raise NotImplementedError
|
src/query/service.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""QueryService — orchestrates plan → validate → compile → execute.
|
| 2 |
+
|
| 3 |
+
Top-level entry point for catalog-driven structured queries. Wired into
|
| 4 |
+
the chat endpoint when source_hint == "structured".
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from ..catalog.models import Catalog
|
| 8 |
+
from .executor.base import QueryResult
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class QueryService:
|
| 12 |
+
"""End-to-end runner for a user question against a catalog."""
|
| 13 |
+
|
| 14 |
+
async def run(self, user_id: str, question: str, catalog: Catalog) -> QueryResult:
|
| 15 |
+
raise NotImplementedError
|
src/retrieval/document.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DocumentRetriever — dense similarity over prose chunks (Cu).
|
| 2 |
+
|
| 3 |
+
For unstructured sources only (PDF / DOCX / TXT). Backed by PGVector with
|
| 4 |
+
collection `document_embeddings`. Methods: MMR, cosine, euclidean, etc.
|
| 5 |
+
|
| 6 |
+
Receives the working implementation from the previous src/rag/retrievers/document.py
|
| 7 |
+
during the cleanup phase; for now this is a placeholder.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class DocumentRetriever:
|
| 12 |
+
"""Dense retrieval over PGVector chunks for unstructured sources."""
|
| 13 |
+
|
| 14 |
+
async def retrieve(self, query: str, user_id: str, k: int = 5) -> list:
|
| 15 |
+
raise NotImplementedError
|
src/retrieval/router.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Retrieval-side router.
|
| 2 |
+
|
| 3 |
+
Currently dispatches only the `unstructured` route to DocumentRetriever.
|
| 4 |
+
The `structured` route is owned by query/service.py — not by retrieval.
|
| 5 |
+
The `chat` route bypasses retrieval entirely.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class RetrievalRouter:
|
| 10 |
+
async def dispatch(self, query: str, user_id: str, source_hint: str) -> list:
|
| 11 |
+
raise NotImplementedError
|
src/security/auth.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication helpers: password hashing, JWT encode/decode, get_user.
|
| 2 |
+
|
| 3 |
+
Receives the working implementation from the previous src/users/users.py
|
| 4 |
+
during the cleanup phase.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def hash_password(plaintext: str) -> str:
|
| 9 |
+
raise NotImplementedError
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def verify_password(plaintext: str, hashed: str) -> bool:
|
| 13 |
+
raise NotImplementedError
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def encode_jwt(payload: dict) -> str:
|
| 17 |
+
raise NotImplementedError
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def decode_jwt(token: str) -> dict:
|
| 21 |
+
raise NotImplementedError
|
src/security/credentials.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fernet-encrypted credential storage for user-registered DB connections.
|
| 2 |
+
|
| 3 |
+
Receives the working implementation from the previous
|
| 4 |
+
src/utils/db_credential_encryption.py during the cleanup phase.
|
| 5 |
+
|
| 6 |
+
Key: settings.dataeyond__db__credential__key (Fernet, kept out of source).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def encrypt_credential(plaintext: str) -> str:
|
| 11 |
+
raise NotImplementedError
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def decrypt_credential(ciphertext: str) -> str:
|
| 15 |
+
raise NotImplementedError
|
src/security/pii_patterns.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regex patterns and column-name heuristics for PII detection.
|
| 2 |
+
|
| 3 |
+
Used by catalog/pii_detector.py at ingestion time. Default policy:
|
| 4 |
+
when in doubt, set pii_flag=True. False positives cost nothing; false
|
| 5 |
+
negatives leak data.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
|
| 10 |
+
PII_NAME_PATTERNS = frozenset({
|
| 11 |
+
"email",
|
| 12 |
+
"phone", "mobile", "telp", "telephone",
|
| 13 |
+
"ssn", "tin", "passport", "ktp", "nik",
|
| 14 |
+
"name", "fullname", "first_name", "last_name", "surname",
|
| 15 |
+
"address", "street", "zipcode", "postal",
|
| 16 |
+
"birthdate", "dob", "birthday",
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
EMAIL_REGEX = re.compile(r"^[\w.+-]+@[\w-]+\.[\w.-]+$")
|
| 20 |
+
PHONE_REGEX = re.compile(r"^\+?[\d\s\-()]{7,}$")
|