# Project export: Data Scout

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Finding the right dataset is always a hassle for the user. Data Scout simplifies this process to enable users to gather multiple datasets without the worry of sourcing.
- Devpost: https://devpost.com/software/dataset-scout
- GitHub: https://github.com/partht555/data-scout
- Team: 2 GitHub contributor(s) — partht555 (24 commits), Manav Gurnani (9 commits)

## Devpost submission (written by the team)

### Inspiration

Finding the right dataset is often harder than building the model that uses it. Search portals can return hundreds of links, but they do not always explain whether a dataset fits a project, contains the needed fields, or is suitable for a particular analysis. We wanted to make dataset discovery feel conversational without making it ungrounded: users should be able to ask for “3 NBA datasets for basketball analysis” or “Formula 1 race strategy data,” then receive real, traceable dataset recommendations rather than AI-invented answers.

### What it does

Data Scout is chat-based dataset discovery assistant. A user describes what they need in plain language. The application: Interprets the request into a safe search plan using Claude Opus on Amazon Bedrock. Searches a live OpenSearch index of enriched Kaggle & Hugging Face metadata. Returns ranked dataset recommendations with canonical Kaggle & Hugging Face links, summaries, match details, files, schema fields when available, and an AI-generated summary of the result set. Falls back safely to deterministic keyword search if AI interpretation is unavailable or invalid. The catalog currently contains more than 1,600 indexed dataset records and updates automatically as the crawler adds or changes metadata.

### How we built it

We built Data Scout as a serverless AWS application. A Python crawler collects public Kaggle & Hugging Face dataset metadata. Amazon Bedrock enriches metadata with structured domain/data-type labels and factual use-case summaries. DynamoDB stores the authoritative dataset records. DynamoDB Streams trigger an index worker that projects active records into an OpenSearch datasets-v1 index. API Gateway routes chat search requests to a Python Lambda. Claude Opus converts user text into a strictly validated search plan: keywords, suggested result count, formats, sources, licenses, required fields, and recency. Lambda builds a bounded OpenSearch query, returns only public result fields, and uses IAM-signed requests. A local static chat UI displays results, match details, schema/file metadata, and a short AI summary of the returned datasets. Chat UI → API Gateway → Query Lambda → Bedrock search plan ↘ OpenSearch → ranked datasets Crawler → DynamoDB → Stream → Index worker → OpenSearch

### Challenges we ran into

The biggest challenge was balancing natural-language flexibility with trustworthy retrieval. Broad queries such as “housing prices” can accidentally match unrelated crypto or stock datasets because words like “price” are common. We built a read-only ranking evaluation harness to compare candidate ranking strategies before changing production behavior. We also encountered sparse metadata: not every dataset has populated file formats or schema fields, so treating AI-suggested formats as hard filters could hide otherwise useful results. We therefore keep model suggestions as soft ranking signals while preserving explicit user filters as hard constraints. Other challenges included handling pagination safely, keeping DynamoDB and OpenSearch synchronized, configuring signed OpenSearch access, avoiding leaked internal search details, and making the chat UI readable as result lists grow.

### Accomplishments we're proud of

Built an end-to-end, live pipeline from crawler to DynamoDB to OpenSearch to chat UI. Indexed more than 1,600 dataset records. Made the AI grounded: Claude interprets and summarizes, while OpenSearch retrieves real dataset records. Added safe fallback behavior when Bedrock fails or returns invalid output. Kept results explainable through canonical links, match details, schema/file data, and factual summaries. Created a ranking evaluation harness so retrieval changes can be tested before deployment. Built a polished chat experience with independent scrolling and compact dataset cards.

### What we learned

We learned that AI is most useful here as an interpreter and assistant—not as an unbounded search engine. Claude is good at turning a vague request into structured intent and explaining a set of real results. OpenSearch is good at fast retrieval over known metadata. Combining the two gives a more reliable experience than asking a model to generate recommendations on its own. We also learned that retrieval quality depends as much on metadata quality and evaluation as it does on model prompts. More datasets improve coverage, but better enrichment, relevance tests, synonyms, and ranking logic are necessary to avoid noisy results.

### What's next

Ingest more sources beyond Kaggle & HF . Improve metadata coverage for files, schemas, licenses, and tags. Use the ranking benchmark to test synonym-aware and phrase-aware retrieval improvements. Add richer filtering controls while keeping chat as the primary interface. Host the frontend for a shareable public demo. Add budget alarms, operational dashboards, and production CORS controls.

## README (from the GitHub repository)

# Data Scout

Data Scout is a chat-based dataset discovery assistant. It interprets a
plain-language request with Bedrock, searches an OpenSearch index of enriched
dataset metadata, and returns grounded Kaggle recommendations.

## Search API

`POST https://lepdzanhh1.execute-api.us-east-1.amazonaws.com/v1/datasets/search`

The deployed Data Scout stack provides API Gateway, Lambda, Amazon Bedrock,
CloudWatch logs, and IAM-signed access to the background-owned OpenSearch index.

## CLI

```powershell
$env:DATA_CURATOR_API_URL = 'https://lepdzanhh1.execute-api.us-east-1.amazonaws.com/v1/datasets/search'
python search_datasets.py 'food datasets for nutrition analysis' --source kaggle --format csv
```

Use `--json` to print the full response. Run `python -m unittest discover -s tests -v`
for the local test suite.

## Read-only ranking evaluation

Compare the current lexical query builder with a phrase-aware candidate without
changing Lambda, DynamoDB, or OpenSearch:

```powershell
$env:OPENSEARCH_ENDPOINT = 'https://your-domain.us-east-1.es.amazonaws.com'
$env:AWS_PROFILE = 'AdministratorAccess-958975572378'
python scripts/evaluate_ranking.py
```

The representative cases are in `scripts/ranking_queries.json`. The harness
uses deterministic keyword plans to avoid Bedrock variability and cost.


## Detected evidence (automated analysis)

Indexed codebase: 51 recognized source files, 221 KB.
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- AWS (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (64 of 64)

```
.gitignore
APP_LAYER_TODO.md
docs/bedrock-search-plan.md
docs/index-worker-contract.md
docs/openapi.yaml
events/api-gateway-search-event.json
events/deployed-search-response.json
events/index-backfill-response.json
events/invalid-search-request.json
events/search-request.json
events/search-response.json
infra/app.py
infra/cdk.json
infra/requirements-dev.txt
infra/requirements.txt
infra/run_cdk_app.js
infra/stacks/__init__.py
infra/stacks/crawler_stack.py
infra/stacks/index_worker_stack.py
infra/stacks/metadata_store_stack.py
infra/stacks/orchestrator_stack.py
infra/tests/__init__.py
infra/tests/test_crawler_stack.py
infra/tests/test_index_worker_stack.py
infra/tests/test_metadata_store_stack.py
infra/tests/test_orchestrator_stack.py
infrastructure/opensearch-domain.yaml
lambdas/huggingface_crawler/bedrock_enricher.py
lambdas/huggingface_crawler/dynamo_writer.py
lambdas/huggingface_crawler/handler.py
lambdas/huggingface_crawler/hf_client.py
lambdas/huggingface_crawler/requirements.txt
lambdas/kaggle_crawler/bedrock_enricher.py
lambdas/kaggle_crawler/dynamo_writer.py
lambdas/kaggle_crawler/handler.py
lambdas/kaggle_crawler/kaggle_client.py
lambdas/kaggle_crawler/requirements.txt
README.md
scripts/evaluate_ranking.py
scripts/ranking_queries.json
search_datasets.py
src/index_worker/__init__.py
src/index_worker/backfill.py
src/index_worker/handler.py
src/query_router/__init__.py
src/query_router/bedrock_adapter.py
src/query_router/handler.py
src/query_router/intent_parser.py
src/query_router/mock_repository.py
src/query_router/opensearch_repository.py
src/query_router/search_handler.py
template.yaml
tests/test_bedrock_adapter.py
tests/test_index_worker.py
tests/test_intent_parser.py
tests/test_mock_search.py
tests/test_opensearch_repository.py
tests/test_query_router.py
tests/test_ranking_evaluation.py
tests/test_search_cli.py
web/app.js
web/config.js
web/index.html
web/styles.css
```

### Dependencies

- infra/requirements.txt: aws-cdk-lib@>=2.140.0, constructs@>=10.0.0
- lambdas/huggingface_crawler/requirements.txt: requests@==2.32.3
- lambdas/kaggle_crawler/requirements.txt: requests@==2.32.3

### Recent commits (newest first)

- Merge pull request #12 from partht555/parth-app
- Merge pull request #11 from partht555/main-manav-improvements
- fixing hf crawler
- UI changewsS
- Merge pull request #10 from partht555/main-manav-improvements
- fixing model usage and new deployed stable version with hugging face stuff
- Merge pull request #9 from partht555/parth-app
- Add ui fixtures
- Use Data Scout project name consistently
- Remove dry run control from submission UI
- Rebrand project as Dataset Scout
- adding hugging face crawler
- Merge pull request #8 from partht555/main-manav-improvements
- adding extra AI summary and toggle + new match details card added to the UI + search and index revisited
- updating model-side enicher for increased range of possible inferred domains
- Merge remote-tracking branch 'origin/parth-app' into main-manav-improvements
- improving limits for crawlers
- Merge pull request #7 from partht555/parth-app
- Add read-only ranking evaluation harness
- Move chat search interpretation server-side

## Key source files (fetched from GitHub, selected and truncated for size)

### APP_LAYER_TODO.md

```markdown
# Application Layer - Implementation TODO

## First goal: deployed dataset-search API

Build one thin, end-to-end path before adding Bedrock, OpenSearch, or a polished frontend:

```text
User query
  -> API Gateway
  -> Query Lambda
  -> OpenSearch `datasets-v1` projection
  -> JSON response
```

**Definition of done:** a deployed endpoint accepts a natural-language dataset request and returns public Kaggle metadata from the active search projection.

## Phase 1 - API contract

- [x] Agree on one endpoint: `POST /v1/datasets/search`.
- [x] Define and document the request body:
  ```json
  {
    "query": "food datasets for nutrition analysis",
    "limit": 5,
    "filters": {
      "source": ["kaggle"],
      "format": ["csv"]
    }
  }
  ```

- [x] Define the response body with `query`, `interpretedIntent`, `results`, and `nextCursor`.
- [x] Define error responses for invalid requests (`400`), no results (`200` with `[]`), and unavailable dependencies (`503`).
- [x] Add example request/response payloads under `events/` or `docs/`.

## Phase 2 - Lambda implementation

- [x] Choose the runtime (recommendation: Python 3.12 for the hackathon).
- [x] Create a Lambda handler that validates `query` and `limit`.
- [x] Create `mock_repository` with 3-5 normalized Kaggle dataset records.
- [x] Return only the public response contract; do not expose internal AWS/OpenSearch details.
- [x] Include cases for a matching query, no matches, and invalid input.
- [x] Add unit tests for validation and result shaping.

## Phase 3 - Infrastructure and deployment

- [x] Add AWS SAM infrastructure-as-code (`template.yaml`).
- [x] Define the Query Lambda, API Gateway route, IAM execution role, and CloudWatch log group.
- [x] Configure `POST /v1/datasets/search` to invoke the Lambda.
- [x] Deploy with the local AWS SSO profile.
- [x] Invoke the deployed endpoint from the CLI and save a successful example response.
- [x] Add resource tags: `project=data-scout`, `environment=hackathon`, and `owner=team`.

## Phase 4 - Minimal client

- [x] Create a small CLI command or frontend form that sends a search request.
- [x] Display title, source, link, summary, schema/format information, and match reasons.
- [x] Add loading, empty-result, and API-error states.
- [x] Keep the UI/CLI dependent only on the documented API contract.

## Phase 4.5 - Optional web hosting

- [x] Scaffold a small static web app ready for AWS Amplify hosting.
- [x] Add a chat search entry that derives query, limit, source, and format filters.
- [x] Configure the API endpoint through an Amplify environment variable; never hard-code it in the UI.
- [x] Display dataset titles, canonical Kaggle links, summaries, formats, schema fields, scores, and match reasons.
- [x] Implement loading, empty-result, invalid-request, and dependency-error states.
- [ ] Configure production CORS only for the selected hosted domain.
- [ ] Build and deploy the static UI with Amplify or another host after the demo; local hosting is 
[truncated — 2195 more characters]
```

### docs/bedrock-search-plan.md

```markdown
# Bedrock search-plan contract

Bedrock will interpret a user request; it will never receive DynamoDB records,
OpenSearch DSL, AWS identifiers, or authority to select dataset links.

The future invocation must return exactly this JSON object:

```json
{
  "task": "forecast retail sales",
  "keywords": ["retail", "sales"],
  "preferredFormats": ["csv"],
  "requiredColumns": ["date", "sales"],
  "sources": ["kaggle"],
  "licenses": [],
  "recency": "recent",
  "confidence": 0.92
}
```

The Lambda validates every field before using it. Explicit request filters win
over model-proposed values. Invalid, timed-out, or unavailable model output
uses deterministic keyword interpretation instead, so retrieval continues
without inventing recommendations.

`BedrockIntentParser` currently accepts an injected invocation function. It
does not create a Bedrock client or make AWS calls; the production adapter will
be added only after selecting a model and narrowing IAM permissions.

```

### infra/requirements.txt

```
aws-cdk-lib>=2.140.0
constructs>=10.0.0

```

### lambdas/huggingface_crawler/requirements.txt

```
requests==2.32.3

```

### lambdas/kaggle_crawler/requirements.txt

```
requests==2.32.3

```

### infra/app.py

```python
#!/usr/bin/env python3
import aws_cdk as cdk
from stacks.metadata_store_stack import MetadataStoreStack
from stacks.crawler_stack import CrawlerStack
from stacks.index_worker_stack import IndexWorkerStack
from stacks.orchestrator_stack import OrchestratorStack

app = cdk.App()

env = cdk.Environment(
    account=app.node.try_get_context("account"),
    region=app.node.try_get_context("region"),
)

metadata_stack = MetadataStoreStack(app, "DataCuratorMetadataStore", env=env)

crawler_stack = CrawlerStack(app, "DataCuratorCrawler", env=env)
crawler_stack.add_dependency(metadata_stack)

index_worker_stack = IndexWorkerStack(
    app,
    "DataCuratorIndexWorker",
    table=metadata_stack.table,
    index_worker_role=metadata_stack.index_worker_role,
    dead_letter_queue=metadata_stack.index_worker_dlq,
    env=env,
)
index_worker_stack.add_dependency(metadata_stack)

orchestrator_stack = OrchestratorStack(app, "DataCuratorOrchestrator", env=env)
orchestrator_stack.add_dependency(crawler_stack)

app.synth()

```

### web/app.js

```javascript
const conversation = document.querySelector("#conversation");
const form = document.querySelector("#search-form");
const input = document.querySelector("#prompt-input");
const chatHistoryList = document.querySelector("#chat-history");
const SESSION_KEY = "data-scout.chats.v2";
const LEGACY_SESSION_KEY = "data-scout.chat.v1";
const MAX_SESSION_CHATS = 20;
const MAX_SESSION_EXCHANGES = 20;
let chatState = loadChatState();

const catalog = [
  {
    datasetId: "kaggle:utsavdey1410/food-nutrition-dataset",
    title: "Food Nutrition Dataset", source: "kaggle", score: 1,
    url: "https://www.kaggle.com/datasets/utsavdey1410/food-nutrition-dataset",
    summary: "Food nutrition information including calories, protein, carbohydrates, and fat content across hundreds of common foods.",
    files: [{ name: "food_nutrition.csv", format: "csv", sizeBytes: null }],
    schema: [
      { name: "food_name", type: "string", nullable: false },
      { name: "calories", type: "number", nullable: true },
      { name: "protein", type: "number", nullable: true },
      { name: "carbohydrates", type: "number", nullable: true },
    ],
    matchedFields: ["title", "tags", "schema.name"],
  },
  {
    datasetId: "kaggle:shivkumarganesh/retail-sales-data",
    title: "Retail Sales Data", source: "kaggle", score: .96,
    url: "https://www.kaggle.com/datasets/shivkumarganesh/retail-sales-data",
    summary: "Retail transactions with dates, products, quantities, and sales amounts suitable for forecasting and trend analysis.",
    files: [{ name: "retail_sales.csv", format: "csv", sizeBytes: null }],
    schema: [
      { name: "date", type: "date", nullable: false },
      { name: "product", type: "string", nullable: false },
      { name: "quantity", type: "integer", nullable: false },
      { name: "sales", type: "float", nullable: true },
    ],
    matchedFields: ["title", "tags", "schema.name"],
  },
  {
    datasetId: "kaggle:rohanrao/formula-1-world-championship-1950-2020",
    title: "Formula 1 World Championship Results", source: "kaggle", score: .91,
    url: "https://www.kaggle.com/datasets/rohanrao/formula-1-world-championship-1950-2020",
    summary: "Formula 1 races, drivers, constructors, lap times, and championship results from 1950 to 2020.",
    files: [
      { name: "races.csv", format: "csv", sizeBytes: null },
      { name: "results.csv", format: "csv", sizeBytes: null },
    ],
    schema: [],
    matchedFields: ["title", "tags"],
  },
];

function buildPayload(query) {
  return { query };
}

function localResponse(payload) {
  const lower = payload.query.toLowerCase();
  const term = lower.includes("food") || lower.includes("nutrition") ? "Food Nutrition Dataset"
    : lower.includes("retail") || lower.includes("sales") || lower.includes("forecast") ? "Retail Sales Data"
    : lower.includes("formula") || lower.includes("race") || lower.includes("sports") ? "Formula 1 World Championship Results"
    : null;
  const results = term ? catalog.filter((item) => item.title === term) : [];
  return { interpretedIntent: { mode: "dry-run", keywords: lower.split(/\s+/), suggestedLimit: 5 }, results: results.slice(0, 5) };
}

function appendMessage(kind, content, { scroll = true } = {}) {
  const article = document.createElement("article");
  article.className = `message ${kind}-message`;
  if (kind === "assistant") {
    article.innerHTML = `<div class="avatar">✦</div><div class="bubble">${content}</div>`;
  } else {
    const bubble = document.createElement("div");
    bubble.className = "bubble";
    bubble.textContent = content;
    article.append(bubble);
  }
  conversation.append(article);
  if (scroll) conversation.scrollTo({ top: conversation.scrollHeight, behavior: "smooth" });
  return article;
}

function scrollMessageToTop(message) {
  const top = conversation.scrollTop + message.getBoundingClientRect().top - conversation.getBoundingClientRect().top - 12;
  conversation.scrollTo({ top, behavior: "smooth" });
}

function renderResults(payload, response) {
  const intent = response.interpretedIntent || {};
  const plan = [
    `limit: ${intent.suggestedLimit || payload.limit || 5}`,
    `mode: ${intent.mode || "dry-run"}`,
    ...(intent.preferredFormats || payload.filters?.format || []).map((value) => `format: ${value}`),
    ...(intent.sources || payload.filters?.source || []).map((value) => `source: ${value}`),
    ...(intent.licenses || payload.filters?.license || []).map((value) => `license: ${value}`),
    ...(intent.requiredColumns || []).map((value) => `field: ${value}`),
  ];
  if (!response.results.length) {
    return `<p>I couldn't find a close match in this preview catalog.</p><p class="result-summary">Try broadening the request or remove a filter.</p><div class="plan">${plan.map((item) => `<span>${item}</span>`).join("")}</div>`;
  }
  const summaryText = response.resultSummary || `I found ${response.results.length} dataset${response.results.length === 1 ? "" : "s"}.`;
  return `<p>${summaryText}</p><div class="plan">${plan.map((item) => `<span>${item}</span>`).join("")}</div><div class="result-list">${response.results.map((item) => renderResult(item, intent)).join("")}</div>`;
}

function renderResult(item, intent) {
  const author = extractAuthor(item);
  const bylineParts = [author ? `by ${author}` : null, item.source].filter(Boolean);
  const byline = bylineParts.length ? `<div class="result-byline">${bylineParts.join(" · ")}</div>` : "";

  const filesText = compactValues(item.files, "name", "format");
  const filesHtml = filesText ? `<div class="result-files">${filesText}</div>` : "";

  const schemaHtml = renderSchema(item.schema, intent);
  const matchHtml = renderMatchDetails(item, intent);

  return `<div class="result">
    <div class="result-top">
      <a class="result-title" href="${item.url}" target="_blank" rel="noreferrer">${item.title}</a>
      <span class="score">${Number(item.score || 0).toFixed(2)}</span>
    </div>
    ${byline}
    <p class="result-sum
[truncated — 8729 more characters]
```

### search_datasets.py

```python
﻿"""Small command-line client for the Data Curator search API."""

from __future__ import annotations

import argparse
import json
import os
import sys
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

DEFAULT_ENDPOINT_ENV = "DATA_CURATOR_API_URL"


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Search the curated dataset catalog.")
    parser.add_argument("query", help="Natural-language dataset request")
    parser.add_argument("--endpoint", default=os.getenv(DEFAULT_ENDPOINT_ENV))
    parser.add_argument("--limit", type=int, default=5)
    parser.add_argument("--source", action="append", choices=["kaggle"])
    parser.add_argument("--format", dest="formats", action="append", choices=["csv", "json", "parquet", "tsv", "xlsx"])
    parser.add_argument("--json", action="store_true", help="Print the API response as JSON")
    args = parser.parse_args(argv)
    if not args.endpoint:
        parser.error(f"--endpoint or {DEFAULT_ENDPOINT_ENV} is required")
    return args


def build_payload(args: argparse.Namespace) -> dict[str, Any]:
    filters = {key: value for key, value in {"source": args.source, "format": args.formats}.items() if value}
    payload: dict[str, Any] = {"query": args.query, "limit": args.limit}
    if filters:
        payload["filters"] = filters
    return payload


def request_search(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
    request = Request(
        endpoint,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json", "Accept": "application/json"},
        method="POST",
    )
    with urlopen(request, timeout=15) as response:  # nosec B310: endpoint is explicit user configuration
        return json.loads(response.read().decode("utf-8"))


def render_text(response: dict[str, Any]) -> str:
    results = response.get("results", [])
    if not results:
        return "No matching datasets found. Try broadening the request or removing filters."

    lines: list[str] = []
    for index, result in enumerate(results, start=1):
        files = ", ".join(file["format"] for file in result.get("files", [])) or "format unavailable"
        schema = ", ".join(field["name"] for field in result.get("schema", [])[:6]) or "schema unavailable"
        reasons = ", ".join(result.get("matchedFields", [])) or "keyword match"
        lines.extend(
            [
                f"{index}. {result['title']} ({result['source']}, score {result['score']:.2f})",
                f"   {result['url']}",
                f"   {result['summary']}",
                f"   Formats: {files} | Fields: {schema}",
                f"   Matched: {reasons}",
            ]
        )
    return "\n".join(lines)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)
    print("Searching catalog...", file=sys.stderr)
    try:
        response = request_search(args.endpoint, build_payload(args))
    except HTTPError as error:
        detail = error.read().decode("utf-8", errors="replace")
        print(f"API error ({error.code}): {detail}", file=sys.stderr)
        return 1
    except URLError as error:
        print(f"Could not reach the search API: {error.reason}", file=sys.stderr)
        return 1

    print(json.dumps(response, indent=2) if args.json else render_text(response))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

### template.yaml

```yaml
﻿AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Data Scout dataset-search API

Parameters:
  Environment:
    Type: String
    Default: hackathon
    AllowedValues: [hackathon]
  OpenSearchEndpoint:
    Type: String
    Default: https://search-data-curator-datasets-u2fu34za3oamw362owrgkql4yu.us-east-1.es.amazonaws.com
  OpenSearchDomainArn:
    Type: String
    Default: arn:aws:es:us-east-1:958975572378:domain/data-curator-datasets
  EnableBedrockIntent:
    Type: String
    Default: "true"
    AllowedValues: ["true", "false"]

Globals:
  Function:
    Runtime: python3.12
    Timeout: 10
    MemorySize: 256
    Tracing: Active
    Tags:
      project: data-scout
      environment: !Ref Environment
      owner: team

Resources:
  SearchHttpApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      StageName: $default
      CorsConfiguration:
        AllowOrigins:
          - http://127.0.0.1:4173
          - http://localhost:4173
        AllowHeaders:
          - content-type
        AllowMethods:
          - POST
      DefaultRouteSettings:
        ThrottlingBurstLimit: 10
        ThrottlingRateLimit: 5
      Tags:
        project: data-scout
        environment: !Ref Environment
        owner: team

  QueryRouterExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${AWS::StackName}-query-router'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess
      Policies:
        - PolicyName: QueryOpenSearchRead
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: [es:ESHttpGet, es:ESHttpPost]
                Resource: !Sub '${OpenSearchDomainArn}/*'
        - PolicyName: QueryBedrockIntent
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: bedrock:InvokeModel
                Resource:
                  - !Sub 'arn:${AWS::Partition}:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/us.anthropic.claude-sonnet-4-6'
                  - arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-6
                  - !Sub 'arn:${AWS::Partition}:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/us.anthropic.claude-haiku-4-5-20251001-v1:0'
                  - arn:aws:bedrock:*::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0
      Tags:
        - Key: project
          Value: data-scout
        - Key: environment
          Value: !Ref Environment
        - Key: owner
          Value: team

  QueryRouterFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub '${AWS::StackName}-query-router'
      CodeUri: src/
      Handler: query_router.handler.lambda_handler
      Role: !GetAtt QueryRouterExecutionRole.Arn
      Environment:
        Variables:
          SEARCH_REPOSITORY: opensearch
          OPENSEARCH_ENDPOINT: !Ref OpenSearchEndpoint
          OPENSEARCH_INDEX: datasets-v1
          ENABLE_BEDROCK_INTENT: !Ref EnableBedrockIntent
          INTENT_MODEL_ID: us.anthropic.claude-haiku-4-5-20251001-v1:0
          SUMMARY_MODEL_ID: us.anthropic.claude-sonnet-4-6
      Events:
        Search:
          Type: HttpApi
          Properties:
            ApiId: !Ref SearchHttpApi
            Path: /v1/datasets/search
            Method: POST

  QueryRouterLogGroup:
    Type: AWS::Logs::LogGroup
    DependsOn: QueryRouterFunction
    Properties:
      LogGroupName: !Sub '/aws/lambda/${QueryRouterFunction}'
      RetentionInDays: 14
      Tags:
        - Key: project
          Value: data-scout
        - Key: environment
          Value: !Ref Environment
        - Key: owner
          Value: team

Outputs:
  SearchApiUrl:
    Description: POST mock dataset-search API endpoint
    Value: !Sub 'https://${SearchHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/v1/datasets/search'
  QueryRouterFunctionName:
    Description: Lambda function name
    Value: !Ref QueryRouterFunction

```

### web/config.js

```javascript
// Local development configuration. An Amplify build can replace this value.
window.DATA_CURATOR_API_URL = "https://lepdzanhh1.execute-api.us-east-1.amazonaws.com/v1/datasets/search";

```

[41 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]