English | 華語
Ask Audrey — AI answers grounded in Audrey Tang's 30-year public transcript archive (archive.tw), with every answer cited back to its original source.
Try it → https://ask.archive.tw/en (華語: https://ask.archive.tw) · Also available as a LINE bot.
English (/en) |
華語 (/) |
|---|---|
![]() |
![]() |
- Retrieval — questions are embedded with
@cf/google/embeddinggemma-300m(768-dim) and matched against the Vectorize indexaskit-audrey-tang(cosine); hits are hydrated through the archive.tw section API for surrounding context. If the Vectorize binding is missing, retrieval falls back to archive.tw full-text search; an empty Vectorize result falls back only for Latin-script questions (the index covers the 華語 transcripts), while 華語 questions get an honest "outside the archive" reply. - Generation — Cloudflare Workers AI runs
@cf/google/gemma-4-26b-a4b-it(pinned insrc/utils/cagEval.ts);[1]-style markers are rewritten to[^1]footnotes linking toarchive.tw/<speech>#s<section_id>. - LINE bot — webhooks must ack within 2 s, so replies are three-stage:
immediate
200 OK(work moves towaitUntil), a typing indicator viachat/loading/start, then a single Reply API call with a Flex Message (answer + up to four source cards). Falls back to the top-2 fuzzy-search sections if CAG fails. A quick character check answers questions that contain only English and symbols (no Han characters) in English — answer, the Flex source labels (Source N/Visit), and the fixed not-found / rate-limit / too-long replies. New followers get a bilingual welcome Flex message keyed on their LINE profile language (non-Chinese → English, otherwise Traditional Chinese); follows without a shareduserIdare acked silently. - Caching — identical questions are served from a 7-day R2 answer cache
(
X-Cache: HIT); retrieval sources are cached in KV for 1 hour. - Abuse protection — two-layer rate limiting (edge limiter 15 req/10 s
per key, then a per-key Durable Object cooldown), a global generation
budget (30/min, 1000/day), 30 s CPU cap, strict CSP and security headers.
Rate-limit hits and over-long questions are logged to a D1 abuse log, and
repeat offenders are auto-blacklisted (default: 3 events in 24 h →
403). Shared-infrastructure ranges (Cloudflare/WARP egress, loopback, private networks) are logged but never blacklisted, so a single shared egress IP can't get innocent users — or a developer on WARP — locked out (issues #49/#50; real-time rate limiting and the global budget still apply). UnbindABUSE_DBand it degrades gracefully (no log, empty blacklist). LINE events with no per-source identity (a 1:1 user who didn't share auserId, so they can't be rate-limited or blacklisted) are acked and dropped; groups/rooms keep theirgroupId/roomIdand respond normally. - Quality — an offline eval harness (
npm run eval:cag,npm run eval:cag:depth) scores answer depth and grounding before model or retrieval changes ship.
| Route | Purpose |
|---|---|
GET / |
鳳問 web app (華語); returning visitors who saved an English preference are redirected client-side to /en |
GET /en |
Ask Audrey web app (English); a saved 華語 preference redirects back to / (client-side) |
GET /privacy · GET /terms |
Legal pages, 華語-first (/en/privacy · /en/terms are English-first twins) |
GET /cag/status |
Current retriever, archive base URL, model and top-k caps |
GET /capacity |
Coarse global generation status ({ "status": "available" | "busy" | "full" }); read-only, short-cacheable |
GET /cag/:question |
Streaming Markdown answer with footnote citations (params via query string, e.g. ?top_k=6&lang=en) |
GET /ask/:question |
Debug: closest single transcript section via the R2 Fuse index |
POST /webhook |
LINE Messaging API webhook (three-stage async reply) |
Requires Node.js 22 or newer (Wrangler 4.87+ requires Node 22; .nvmrc and
.node-version are included for common version managers).
npm install
npx wrangler login # first run opens a browser for OAuthThe Fuse index lives in askit-fuse-index-cache (with a -preview twin for
wrangler dev); the answer cache uses a separate askit-answer-cache bucket
(the code degrades gracefully — treats reads as cache misses — if a bucket is
missing):
npx wrangler r2 bucket create askit-fuse-index-cache
npx wrangler r2 bucket create askit-fuse-index-cache-preview
npx wrangler r2 bucket create askit-answer-cache
npx wrangler r2 bucket create askit-answer-cache-preview
npm run r2:lifecycle # apply the 7-day lifecycle rule to the answer-cache bucketsnpm run vectorize:create # askit-audrey-tang, 768 dimensions, cosine
npm run vectorize:sync # backfill embeddings from the transcript archiveThe retrieval-source cache (1 hour TTL) lives in KV:
npx wrangler kv namespace create CAG_CACHE
npx wrangler kv namespace create CAG_CACHE --previewPut the printed ids into the kv_namespaces block of wrangler.jsonc.
npm run build:indexOptional environment variables:
| Var | Default | Notes |
|---|---|---|
D1_DATABASE |
sayit-database |
D1 database name (must match sayit-hono) |
SPEAKER_LIKE |
唐鳳% |
speakers.name LIKE condition |
R2_BUCKET |
askit-fuse-index-cache |
Target R2 bucket |
R2_KEY |
ask-index/audrey-tang.json |
R2 object key |
R2_MANIFEST_KEY |
ask-index/audrey-tang.manifest.json |
Tiny sidecar manifest key, uploaded only after the index JSON succeeds |
MAX_SECTION_CHARS |
175 |
Keep sections whose plain-text length is at most this value |
YEARS_BACK |
2 |
Keep transcripts from the last N years |
LOCAL=1 |
— | Use --local against D1 (defaults to --remote) |
SKIP_UPLOAD=1 |
— | Write the JSON to build/ only, skip the R2 upload |
Upload order is "large index JSON first, manifest second." The Worker treats the manifest as the version signal, so it does not switch before R2 has the new index object.
Only needed if you want the LINE bot; the web app works without it.
LINE webhook setup
Two secrets are required:
| Name | Purpose |
|---|---|
LINE_CHANNEL_ACCESS_TOKEN |
Bearer token used when calling the LINE Reply API |
LINE_CHANNEL_SECRET |
HMAC-SHA256 key used to verify webhook request signatures |
Both are sensitive — do not put them in wrangler.jsonc or commit them.
Upload them to your Cloudflare account (production does not read
.dev.vars):
npx wrangler secret put LINE_CHANNEL_ACCESS_TOKEN
npx wrangler secret put LINE_CHANNEL_SECRETEach prompts for the value — paste and press Enter. Cloudflare stores it
encrypted and injects it at runtime as c.env.LINE_CHANNEL_ACCESS_TOKEN /
c.env.LINE_CHANNEL_SECRET. Verify with npx wrangler secret list; re-run
secret put to overwrite. (Secrets attach to a deployed Worker, so in
practice you npm run deploy once first; until they are set, webhook calls
return 401.)
To obtain the values:
- Open LINE Developers Console and create a Provider + Messaging API Channel
- Channel secret: shown on the channel's Basic settings tab — copy it
- Channel access token: on the Messaging API tab, scroll to the bottom and Issue a long-lived Channel access token
After deploy, take the printed Worker URL, append /webhook (e.g.
https://askit-hono.YOUR-SUBDOMAIN.workers.dev/webhook), paste it into the
Webhook URL field of your channel, and enable the webhook. Click
Verify on that page to confirm signature verification works end-to-end.
How verification works: LINE computes HMAC-SHA256 over the raw request body
using your Channel secret, Base64-encodes it, and sends it in the
x-line-signature header. The Worker recomputes the MAC with the same key
and compares them in constant time. Mismatches (including a missing header)
return 401 Invalid signature.
To test the webhook with curl:
SECRET="your-channel-secret"
BODY='{"events":[]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)
curl -X POST https://YOUR-WORKER-URL/webhook \
-H "Content-Type: application/json" \
-H "x-line-signature: $SIG" \
-d "$BODY"An empty events array returns 200 OK. /webhook always acks within 2 s
and does the slow work in ctx.waitUntil, so a faked replyToken only logs
a background Reply failure — to verify real replies, message the bot from the
LINE app.
The answer endpoints (/au, /cag, /ask, and /capacity) are protected by
per-IP rate limiting, a D1 blacklist, and a global generation budget. Automated
tooling that calls them from a non-browser User-Agent can be throttled (429)
or blocked (403). To let a trusted caller bypass all three, present a bearer
token that matches the AUDREYT_TRANSCRIPT_TOKEN secret (this mirrors the
sibling sayit-hono project, which uses the same env var name, the same
Authorization: Bearer <token> header, and the same constant-time SHA-256
comparison).
| Name | Purpose |
|---|---|
AUDREYT_TRANSCRIPT_TOKEN |
Trusted-caller bearer token; a matching request skips rate limiting, the blacklist, and the global generation budget |
It is sensitive — do not put it in wrangler.jsonc or commit it. Upload it
to your Cloudflare account:
npx wrangler secret put AUDREYT_TRANSCRIPT_TOKENThen a trusted client calls the answer endpoints with the bearer header:
curl https://ask.archive.tw/au/$(python3 -c "import urllib.parse;print(urllib.parse.quote('你怎麼看開放政府'))") \
-H "Authorization: Bearer $AUDREYT_TRANSCRIPT_TOKEN"When the secret is unset, every request follows the normal rate-limit / blacklist path; the header has no effect.
npm run deployThis repo includes .github/workflows/refresh-cag-index.yml to refresh the
R2 Fuse index used by /ask and the LINE fallback. /cag reads the
archive.tw APIs directly, so it sees new content once sayit-hono deploys.
The workflow has three entrypoints:
repository_dispatcheventsayit-updated: intended for thetranscriptrepo after Markdown upload andsayit-honodeploy succeed.workflow_dispatch: manual index refresh; setdeploy=trueto deploy the Worker too as an immediate cache reset.- Daily schedule: a backstop if a dispatch is missed.
Recommended final step in the transcript repo's Sync markdown on push
workflow, after the rebuild-search-index job deploys sayit-hono:
- name: Refresh AskIt index
env:
GH_TOKEN: ${{ secrets.ASKIT_REBUILD_TOKEN }}
run: |
gh api repos/bestian/askit-hono/dispatches \
-f event_type=sayit-updated \
-F client_payload[transcript_sha]="${GITHUB_SHA}"ASKIT_REBUILD_TOKEN must be able to send repository dispatches to
bestian/askit-hono; a fine-grained PAT with Contents: read/write for that
repo works. Once the dispatch arrives, askit-hono runs npm run build:index,
uploads the refreshed R2 index and manifest, then dry-run validates the
Worker bundle. The live Worker sees the manifest change and reloads the
/ask index within roughly one minute; deploy is kept only as a manual
cache reset.
cp .dev.vars.example .dev.vars # only needed for /webhook (LINE secrets)
# Edit .dev.vars and paste real values
npm run dev # local Worker + remote R2 / Workers AI bindings
npm run preview # wrangler dev --remote — the Worker itself also runs on CloudflareNote:
ASK_INDEXandAIare markedremote: trueinwrangler.jsonc. Local/asktests read the cloud preview R2 bucket; local/cagtests callarchive.twAPIs and Workers AI. That uses your Cloudflare account quota. If the preview bucket does not have the/askindex yet, seed it first:
npx wrangler r2 object put 'askit-fuse-index-cache-preview/ask-index/audrey-tang.json' \
--file build/audrey-tang.json \
--content-type 'application/json; charset=utf-8' \
--remote
npx wrangler r2 object put 'askit-fuse-index-cache-preview/ask-index/audrey-tang.manifest.json' \
--file build/audrey-tang.manifest.json \
--content-type 'application/json; charset=utf-8' \
--remoteYou can hit /ask/:question directly while developing:
curl 'http://127.0.0.1:8787/ask/AI%E6%9C%83%E4%B8%8D%E6%9C%83%E6%8E%A7%E5%88%B6%E6%88%91%E5%80%91'Streaming CAG test:
curl -N 'https://YOUR-WORKER-URL/cag/%E7%94%A8%20%23zh-tw%20%E5%9B%9E%E7%AD%94%EF%BC%9A%E5%9C%B0%E7%A5%9E%E9%A6%99%E7%81%AB%E5%A6%82%E4%BD%95?top_k=6'| Script | What it does |
|---|---|
npm run dev / npm run preview |
Local Worker (remote R2/AI bindings) / fully remote |
npm run deploy |
Deploy to Cloudflare Workers |
npm test / npm run typecheck |
Node test suite / TypeScript check |
npm run build:index |
Build the Fuse index from D1 and upload to R2 |
npm run vectorize:create / vectorize:sync |
Create / backfill the Vectorize index |
npm run eval:cag / eval:cag:depth |
Model / retrieval-depth eval harnesses |
npm run r2:lifecycle |
Apply the 7-day lifecycle to the answer-cache buckets |
npm run abuse:db:create / abuse:db:init |
Create / initialise the askit-abuse-log D1 database (:local for dev) |
npm run abuse:report |
Analyse the abuse log into build/abuse-report.html (LOCAL=1 for local D1) |
npm run abuse:unban -- <key> |
Remove a key from the blacklist (also clears its old log entries) |
npm run tail |
Live-tail Worker logs |
npm run skill:mine |
Mine Audrey's voice metrics from D1 into skill/outputs/voice-metrics.json (see Audrey Tang skill) |
A standalone, portable persona skill lives in skill/ —
it lets an AI assistant answer questions in Audrey's conversational,
reframing, optimistic style, grounded in her
archive.tw transcript archive with every substantive
claim cited back to a source section. The skill delegates retrieval to
the existing /cag + archive.tw + Vectorize stack — it builds no new
index and changes nothing in the deployed Worker.
The voice metrics in skill/outputs/voice-metrics.json (signature-phrase
counts, openings, closings, analogies) are mined from the D1 sections
table by scripts/mine-audrey-voice.ts. Regenerate them when the archive
grows:
npm run skill:mineOptional env vars (SPEAKER_LIKE / SPEAKER_LIKE_EN for the 華語 / English
speaker filters, TOP_N, SAMPLE, OUT, LOCAL=1) mirror
npm run build:index. The miner refuses to write an empty corpus and warns
loudly if either language branch comes back empty (likely a speaker-filter
mismatch). The skill files (skill/SKILL.md and skill/references/*.md)
are hand-curated from the mined evidence — see
skill/references/sources.md for the
maintenance workflow.
.
├── src/
│ ├── index.ts # Hono app: routes, webhook, rate limiting
│ ├── pages/ # Server-rendered pages (home/privacy/terms, zh + en)
│ └── utils/
│ ├── cag.ts # CAG retrieval + generation + citations
│ ├── vectorize.ts # Embeddings + Vectorize query
│ ├── cagCache.ts # KV source cache (1 h)
│ ├── cache.ts # R2 answer cache (7 days)
│ ├── cagEval.ts # Eval scoring (incl. pinned model id)
│ ├── abuse.ts # D1 abuse log + auto-blacklist (issue #27)
│ ├── notFoundReply.ts # Out-of-scope replies (plain + HTML, zh + en)
│ ├── search.ts # R2 Fuse index loader + fuzzy search
│ └── askIndexFormat.ts # Shared index types/options
├── public/ # Static assets + Vue front-end (app.js)
├── db/ # D1 schema for the abuse log + blacklist
├── scripts/ # build-ask-index / vectorize-sync / evals / abuse ops / skill:mine
├── test/ # node --test suites
├── skill/ # Portable Audrey Tang persona skill (SKILL.md + references/)
├── design/ # Architecture notes + system diagram
├── config/ # R2 lifecycle rules
└── wrangler.jsonc # Workers config (R2, KV, Vectorize, AI, DO)
- sayit-hono — the archive.tw backend this bot retrieves from
- transcript — the source transcripts
See CONTRIBUTING.md and SECURITY.md.
MIT © bestian

