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: 14 additions & 5 deletions backend/hansard-search/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# hansard-search Lambda

**Slice 1 of 3** — scaffold, S3 manifest reader, and SQLite index downloader.
**Slices 1-2 of 3** — scaffold, S3 manifest reader, SQLite index downloader,
and the FTS5 query layer.

This Lambda serves `GET /api/v1/hansard/search`. D1 returns HTTP 503 with
`{"error":"search index not yet available"}` for every request. D2 adds the
FTS5 query adapter and use case; D3 wires the real HTTP handler and OpenAPI spec.
This Lambda serves `GET /api/v1/hansard/search`. It still returns HTTP 503
until D3 wires the HTTP handler, but D2 adds the `SearchHansard` use case and
the `sqlitefts5` adapter that execute FTS5 `MATCH` queries against the downloaded
SQLite index.

## Environment variables

Expand Down Expand Up @@ -43,6 +45,12 @@ hansard-search-index Lambda (EPAC-2062):
4. Opens the file read-only (`file:/tmp/index.sqlite?mode=ro&_pragma=query_only(1)`),
reads `meta.version`, and returns `ErrSchemaMismatch` if it is not `v1`.

`SearchHansard` use case (query path, added in D2):
1. Validates a non-empty FTS5 query and 1-indexed pagination (`1 <= per_page <= 100`).
2. Sanitizes control characters from the `MATCH` expression while preserving FTS5 operators.
3. Queries the SQLite FTS5 index with optional speaker/topic substring filters.
4. Maps FTS5 parser failures from `modernc.org/sqlite` to `ErrInvalidQuerySyntax`.

## Architecture

```
Expand All @@ -52,10 +60,11 @@ cmd/main.go

internal/
domain/ — Manifest value object
usecase/ — OpenSearchIndex (port interfaces ManifestLoader, IndexDownloader)
usecase/ — OpenSearchIndex and SearchHansard application policy
adapter/
s3manifest/ — ManifestLoader backed by S3
sqlitefile/ — IndexDownloader: S3 download + SHA-256 + schema-version check
sqlitefts5/ — HansardSearchRepository backed by SQLite FTS5
```

Dependency rule: `usecase/` has no imports from `aws-sdk-go-v2` or `modernc.org/sqlite`.
Expand Down
136 changes: 136 additions & 0 deletions backend/hansard-search/internal/adapter/sqlitefts5/repository.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package sqlitefts5

import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"unicode"

"epac/hansard-search/internal/usecase"

sqlite "modernc.org/sqlite"
)

const searchSQL = `
SELECT
i.parliament_number, i.session_number, i.sitting_date,
i.intervention_id, m.message_id,
i.speaker_name, i.party_abbreviation, i.riding_name, i.topic,
snippet(messages_fts, 0, '<mark>', '</mark>', '…', 32) AS snippet,
bm25(messages_fts) AS score
FROM messages_fts
JOIN messages m ON m.rowid = messages_fts.rowid
JOIN interventions i ON i.rowid = m.intervention_rowid
WHERE messages_fts MATCH ?
AND (? = '' OR i.speaker_name LIKE '%' || ? || '%' COLLATE NOCASE)
AND (? = '' OR i.topic LIKE '%' || ? || '%' COLLATE NOCASE)
-- bm25() typically returns negative numbers; lower scores rank better.
ORDER BY score ASC
LIMIT ? OFFSET ?
`

const countSQL = `
SELECT COUNT(*)
FROM messages_fts
JOIN messages m ON m.rowid = messages_fts.rowid
JOIN interventions i ON i.rowid = m.intervention_rowid
WHERE messages_fts MATCH ?
AND (? = '' OR i.speaker_name LIKE '%' || ? || '%' COLLATE NOCASE)
AND (? = '' OR i.topic LIKE '%' || ? || '%' COLLATE NOCASE)
`

type Repository struct {
db *sql.DB
}

var _ usecase.HansardSearchRepository = (*Repository)(nil)

func New(db *sql.DB) *Repository {
return &Repository{db: db}
}

func (r *Repository) Search(ctx context.Context, q usecase.SearchQuery, p usecase.Pagination) (usecase.SearchResults, error) {
if r.db == nil {
return usecase.SearchResults{}, errors.New("sqlite database is required")
}

query := sanitizeQuery(q.Query)
speaker := strings.TrimSpace(q.Speaker)
topic := strings.TrimSpace(q.Topic)

var total int
if err := r.db.QueryRowContext(ctx, countSQL, query, speaker, speaker, topic, topic).Scan(&total); err != nil {
return usecase.SearchResults{}, classifySearchError(err)
}

offset := (p.Page - 1) * p.PerPage
rows, err := r.db.QueryContext(ctx, searchSQL, query, speaker, speaker, topic, topic, p.PerPage, offset)
if err != nil {
return usecase.SearchResults{}, classifySearchError(err)
}
defer rows.Close()

hits := make([]usecase.SearchHit, 0)
for rows.Next() {
var hit usecase.SearchHit
if err := rows.Scan(
&hit.ParliamentNumber,
&hit.SessionNumber,
&hit.SittingDate,
&hit.InterventionID,
&hit.MessageID,
&hit.SpeakerName,
&hit.PartyAbbreviation,
&hit.RidingName,
&hit.Topic,
&hit.Snippet,
&hit.Score,
); err != nil {
return usecase.SearchResults{}, classifySearchError(err)
}
hits = append(hits, hit)
}
if err := rows.Err(); err != nil {
return usecase.SearchResults{}, classifySearchError(err)
}

return usecase.SearchResults{
Total: total,
Hits: hits,
}, nil
}

func sanitizeQuery(query string) string {
query = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return ' '
}
return r
}, query)

return strings.TrimSpace(query)
}

func classifySearchError(err error) error {
if err == nil {
return nil
}
if isInvalidQuerySyntax(err) {
return usecase.ErrInvalidQuerySyntax
}
return fmt.Errorf("search hansard sqlite index: %w", err)
}

func isInvalidQuerySyntax(err error) bool {
var sqliteErr *sqlite.Error
if !errors.As(err, &sqliteErr) {
return false
}

message := strings.ToLower(sqliteErr.Error())
return strings.Contains(message, "fts5: syntax error") ||
strings.Contains(message, "unterminated string") ||
strings.Contains(message, "malformed match expression")
}
Loading
Loading