# fable7-transcripts API

Read-only HTTP API for meeting transcripts.
Domain: `fable7-transcripts.feedmob.it.com` · JSON unless noted · timestamps are GMT.

## Conventions

- `date` strings are ISO `YYYY-MM-DD`, used in filter params and item metadata.
- `modified` is an integer Unix epoch (seconds, GMT).
- `filename` in responses is the literal basename, e.g. `2026-07-08_KenTeam早会_Transcript.vtt`. Pass it to `/meetings/{filename}` after URL-encoding the Chinese chars.
- Filename whitelist: `^[A-Za-z0-9_\-\u4e00-\u9fff\.]+\.vtt$`. Anything else, including path traversal `..`, returns `404`.
- On-disk pattern is `YYYY-MM-DD_<meeting name>_Transcript.vtt`; files outside this pattern are silently skipped in `/meetings` and `/search`.

## Authentication

Three ways to pass the API key, all equivalent. `GET /health` and `GET /docs*` are public.

### Authorization header (preferred for programmatic access)

```bash
curl -H "Authorization: Bearer $KEY" \
  'http://127.0.0.1:8001/meetings?limit=5'
```

### Query parameter `api_key`

```bash
curl 'http://127.0.0.1:8001/meetings?limit=5&api_key=YOUR_KEY'
```

### Short alias `k`

```bash
curl 'http://127.0.0.1:8001/meetings?limit=5&k=YOUR_KEY'
```

Missing or invalid key → `401`.

## Endpoints

### `GET /health` — public

Liveness probe. No auth.

```bash
curl http://127.0.0.1:8001/health
```

```json
{ "status": "ok", "transcripts": true }
```

### `GET /docs` — public

HTML rendering of this page (markdown rendered to HTML, basic CSS).

### `GET /docs.md` — public

Raw markdown, `Content-Type: text/markdown; charset=utf-8`. Best for `curl` and AI ingestion.

### `GET /docs.json` — public

Structured metadata (endpoints, params, auth, sample responses), `Content-Type: application/json; charset=utf-8`. Stable field names for tools.

### `GET /ui/` — public

Transcript browser SPA (the original interactive UI). HTML page that loads `index.html`. Root `/` also serves the API docs, so this is where the browser lives.

### `GET /meetings` — auth required

List transcripts with pagination and filtering.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `meeting` | string | — | Substring match on meeting name segment |
| `date_from` | `YYYY-MM-DD` | — | Inclusive lower bound |
| `date_to` | `YYYY-MM-DD` | — | Inclusive upper bound |
| `limit` | int 1-1000 | 100 | Page size |
| `offset` | int ≥0 | 0 | Page offset |

```bash
curl -H "Authorization: Bearer $KEY" \
  'http://127.0.0.1:8001/meetings?date_from=2026-07-01&date_to=2026-07-12&limit=10'
```

```json
{
  "total": 3,
  "limit": 10,
  "offset": 0,
  "items": [
    {
      "filename": "2026-07-08_KenTeam早会_Transcript.vtt",
      "date": "2026-07-08",
      "meeting": "KenTeam早会",
      "size": 14820,
      "modified": 1752614400
    }
  ]
}
```

### `GET /meetings/{filename}` — auth required

Raw VTT body, `Content-Type: text/plain; charset=utf-8`. URL-encode the filename.

```bash
curl -H "Authorization: Bearer $KEY" \
  'http://127.0.0.1:8001/meetings/2026-07-08_KenTeam%E6%97%A9%E4%BC%9A_Transcript.vtt'
```

Returns `404` if the filename is outside the whitelist or contains `..`, `/`, or `\`.

### `GET /search` — auth required

Full-text search via ripgrep.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `q` | string 1-200 | required | ripgrep regex pattern |
| `meeting` | string | — | Substring match on meeting name |
| `date_from` | `YYYY-MM-DD` | — | Inclusive |
| `date_to` | `YYYY-MM-DD` | — | Inclusive |
| `context` | int 0-10 | 2 | Lines of context per hit |
| `limit` | int 1-500 | 50 | Max files in result |

```bash
curl -H "Authorization: Bearer $KEY" \
  'http://127.0.0.1:8001/search?q=KenTeam'
```

```json
{
  "query": "KenTeam",
  "files_matched": 1,
  "results": [
    {
      "filename": "2026-07-08_KenTeam早会_Transcript.vtt",
      "date": "2026-07-08",
      "meeting": "KenTeam早会",
      "hits": [
        { "line": 12, "text": "[00:01:23.456] KenTeam 周会开始" },
        { "line": 47, "text": "[00:12:01.000] 今天 KenTeam 主要讨论三个议题" }
      ]
    }
  ]
}
```

## Errors

| Code | When |
|------|------|
| `401` | Missing or invalid API key (any of the three auth methods) |
| `404` | File not found, filename outside whitelist, or path traversal attempt |
| `405` | Non-GET method on any path. Body: `{"detail": "read-only: only GET is allowed"}` |
| `500` | Search backend (ripgrep) failure; stderr surfaced in detail |
| `503` | Transcripts directory unavailable |

## Limits

- No rate limit. Be polite anyway.
- `/search` runs ripgrep with a **20s** timeout. Very large corpora or pathological regexes may hit it.
- `/meetings` returns at most `limit` items; paginate with `offset`.
- All write methods (`POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`) return `405` on every path. The service does not write anything under the transcripts directory. Indexing (write side of the vector KB) is a **local CLI** operation at `/opt/transcripts-api/index.py` — there is intentionally no HTTP endpoint for it, to keep API spend under operator control.

## Vector search

Backed by PostgreSQL 17 + pgvector; chunks embedded via OpenRouter `openai/text-embedding-3-small` (1536 dims). The HTTP surface only exposes the read side; indexing is local.

### `GET /search/semantic` — auth required

Vector (cosine) similarity search over transcript chunks. Embeds the query via OpenRouter and returns the top-K most similar chunks. Cost is one embedding call per request.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `q` | string 1-2000 | required | natural-language query (Chinese or English) |
| `meeting` | string | — | Substring match on meeting name |
| `date_from` | `YYYY-MM-DD` | — | Inclusive |
| `date_to` | `YYYY-MM-DD` | — | Inclusive |
| `limit` | int 1-50 | 10 | Max chunks in result (filters applied after KNN) |

```bash
curl -H "Authorization: Bearer ***   'http://127.0.0.1:28765/search/semantic?q=AI会议主题&limit=5'
```

```json
{
  "query": "AI会议主题",
  "files_matched": 3,
  "results": [
    {
      "filename": "2026-07-10_KenTeam早会_Transcript.vtt",
      "date": "2026-07-10",
      "meeting": "KenTeam早会",
      "hits": [
        {
          "speaker": "Windy",
          "start_ts": "00:12:34.560",
          "end_ts": "00:13:01.230",
          "score": 0.521,
          "chunk_idx": 47,
          "text": "因为。 对。 对，因为昨天的AI会议。 发的这个。"
        }
      ]
    }
  ]
}
```

### Indexing CLI — local only

There is **no HTTP endpoint** for indexing. Run the CLI directly on the host:

```bash
# Add any new files not yet indexed (the common case, e.g. after sync.py)
python /opt/transcripts-api/index.py add

# Add one specific file
python /opt/transcripts-api/index.py add 2026-07-14_Ai会议-重复_Transcript.vtt

# Show current index stats
python /opt/transcripts-api/index.py status

# Full rebuild (rare — wipes everything and re-embeds from scratch)
python /opt/transcripts-api/index.py rebuild
```

`add` runs cheaply: it queries `SELECT DISTINCT filename FROM transcript_chunks` and only embeds files that aren't there yet. `rebuild` runs full TRUNCATE + re-embed of every `.vtt` on disk and is the only operation that meaningfully spends on the OpenRouter quota.

On any failure (one or more files), the CLI sends a single Slack DM to `leo_yang` summarising which files failed and why. Success runs log to `logs/access.log` only — no DM unless requested.
