RAG-based volunteer chatbot for Eilaf, with scraping, embedding, and speech-to-text units.
The project is installable and locked with uv.
Create the environment from uv.lock with a single command.
The env is placed under $ENVS_DIR if that variable is set, otherwise in the
project directory (./eilaf):
UV_PROJECT_ENVIRONMENT="${ENVS_DIR:-.}/eilaf" uv sync --all-extrasuv reads the venv location from the UV_PROJECT_ENVIRONMENT environment
variable (it can't be a conditional path in pyproject.toml), and
${ENVS_DIR:-.} expands to $ENVS_DIR when set and . otherwise.
--all-extras matters on a dev machine. Two units live in optional
extras so the WhatsApp image doesn't carry them — apps (Streamlit, for
app/visualizer) and stt (faster-whisper, for transcription/). A bare
uv sync gives you the scraping, retrieval and WhatsApp stack but no
Streamlit, and streamlit run … will fail with ModuleNotFoundError. Take
one extra with --extra apps if you'd rather not pull ctranslate2.
GPU note: the lock pins the default PyPI
torch. On a CUDA machine, add the appropriate[tool.uv.sources]/ index for thecuXXXwheels before syncing so you don't overwrite an existing GPU stack.
The RAG pipeline reads data/static/enriched/. That directory is produced by
the stages below, run in order (all re-runnable and incremental) — or by
scripts/curate_data.sh, which chains them:
web / .docx ──scrape──▶ data/static/raw/*.json ──enrich──▶ data/static/enriched/*.json
│
embed ▼
data/static/enriched/.embeddings/
Run this before the app — a fresh clone has no corpus. The scraped and enriched documents are deliberately kept out of git (
.gitignoreexcludesdata/static/**/*.jsonand friends), sodata/static/raw/anddata/static/enriched/start out empty. The app still starts on an empty corpus — it fails quietly, returning blank answers to every question rather than erroring — so if the UI comes up but answers nothing, that's the cause. The.docxsources are local/private files, so a clone can only rebuild the web-scraped part of the corpus.
Run everything from the repo root, with the env active (or prefixed with
uv run).
Each scraper module has a __main__ block holding the source URLs it was last
run with; edit that list to add sources.
python -m data.scraping.nevo # Israeli law texts (modern + legacy templates)
python -m data.scraping.kol_zchut # KolZchut rights pages, via the MediaWiki API
python -m data.scraping.gov_il # gov.il guides and information pages, via its content API
python -m data.scraping.docx_source # local .docx drafts → the same section-tree JSONOutput is one JSON section tree per source in data/static/raw/, named after
the URL. docx_source globs a local folder — point it at your own path before
running.
gov.il: the site is a single-page app behind Cloudflare bot management — its HTML is an empty shell and the request is refused on its TLS fingerprint, so there is nothing to scrape.
gov_il.pyinstead calls the content API the site's own front end uses, on a host that is not behind Cloudflare. Requests need both anx-client-idand anOrigin: https://www.gov.ilheader; withoutOriginthe gateway answers500 RF-OriginError, which looks like a server fault rather than a rejected request. gov.il publishes no sitemap, solist_pages()in that module discovers page URLs from a topic or office landing page.
Time: one HTTP request per page, so this is fast — measured 0.4–1.2 s per page (the largest legacy nevo law is the slow end), i.e. a few seconds for the whole current source list. Adding many pages scales linearly.
Every text segment gets a useful flag plus age/gender/track tags, via an LLM.
Re-runs reuse the previous enriched copy and reject obvious junk by rule, so
only new or changed segments are ever billed.
track marks content belonging to a separate compensation track — hostile
acts, military bereavement, road or work accidents — which Israeli law routes
through its own authorities and eligibility tests. That material reads like
ordinary victim support, so without the tag retrieval offers it for the
neighbourhood-violence questions this bot exists to answer, and the entitlements
do not carry over. It is the one axis that penalises on a missing fact: a
tagged chunk is off-track until a user fact affirms it. A source devoted
entirely to one track can also stamp track on its tree root (see
_HOSTILE_ACTS in data/scraping/gov_il.py), which the loader inherits down to
every segment — per-segment inference cannot catch a lone sentence like "the
allowance is paid monthly", but the page it came from knows.
python -m data.enrich --dry-run # count what would be sent; calls nothing
python -m data.enrich --no-llm # rules-only pass, no API key needed
python -m data.enrich --provider claude --limit 500 # budgeted LLM run--provider picks the back-end: claude (default, Message Batches API),
gemini (Google AI free tier), or cerebras. Neither back-end has a spend
cap, hence --dry-run / --limit N — always dry-run first.
gemini is the working free back-end. cerebras was the bulk option on its
free tier, but as of 2026-08-18 the key returns 402 payment_required on every
call, so it needs billing (or a swap to Groq/OpenRouter — a one-line change in
openai_enricher.py) before it can carry the ~10k nevo backlog.
A segment the back-end fails on is left un-annotated and stays pending, so a
later run retries it; the run ends with a loud WARNING: N segments failed if
any did. Watch for that — a run can otherwise exit 0 having answered nothing,
which is exactly what a retired model name does. Google retires Gemini models
often, so if --provider gemini starts reporting model errors, check
ROTATION_MODELS in data/enrich/gemini_enricher.py against
client.models.list().
Time:
--dry-runand--no-llmare seconds (pure local work). The LLM pass is the long one: the current raw corpus has ~10,000 segments still pending.claudesubmits them as a batch and polls every 30 s — typically minutes to a couple of hours (Anthropic's batch SLA is 24 h).geminiandcerebrassend one sequential request per segment (≈1 s each), so a full 10k pass is hours, and Gemini's free-tier daily quotas will spread it over days. Budget it with--limitand run it repeatedly; nothing is lost between runs.
The corpus is embedded once and cached on disk
(data/static/enriched/.embeddings/), fingerprinted by corpus and embedder.
The app does this lazily on its first query — which is fine for Streamlit but
not for a webhook, where the first user would wait out the whole encode. Warm it
ahead of time:
python -m chatbot.rag.pipeline.warm_cacheA no-op (a sub-second load) when neither the corpus nor the embedder changed, so it is safe to run on every boot; a full re-encode — around an hour on CPU — when either did.
scripts/curate_data.sh chains scrape → enrich → embed in the only order they
work in. Everything is incremental, so re-running it is cheap once the corpus
exists:
./scripts/curate_data.shStages are non-fatal by default: a refused scrape or an exhausted LLM quota is reported and the run continues, so the embed stage still publishes whatever corpus exists. The script exits non-zero if anything failed. Environment variables tune it:
| Variable | Default | What it does |
|---|---|---|
CURATE_SKIP_SCRAPE / _ENRICH / _EMBED |
0 |
1 skips that one stage |
CURATE_SCRAPERS |
nevo kol_zchut gov_il |
which data.scraping modules to run (docx_source is out: it globs a private local folder) |
ENRICH_PROVIDER |
gemini |
claude / gemini / cerebras |
ENRICH_LIMIT |
(unset) | cap segments sent to the LLM this run; unset means the whole pending backlog, which can take hours |
ENRICH_ARGS |
(unset) | extra flags for python -m data.enrich, e.g. --no-llm |
CURATE_STRICT |
0 |
1 aborts on the first stage failure |
PYTHON |
python |
interpreter to run the stages with |
Everything above hangs off one root, data/static/ by default. Set
EILAF_STATIC_DIR to move the whole store — raw/, enriched/ and
.embeddings/ travel together, and scrapers, enrichment, the apps and the cache
all follow it:
EILAF_STATIC_DIR=/mnt/eilaf-static ./scripts/curate_data.shRead once, at import, so it has to be a real environment variable (docker
compose's env_file makes one; a value set from Python later will not be seen).
An empty value falls back to the repo-local default.
For a deployment (EC2, S3) this is the hook to use, but note it is a
filesystem path — the corpus store does not speak the S3 API. (One thing now
does: app/whatsapp/reporting writes reported conversations to a bucket with
boto3. The two are unrelated — a report never lands in the corpus.) Point it at S3 through a
mount (mountpoint-s3, s3fs) or sync the bucket to local disk before start
and point it at that copy. A synced local copy is the safer of the two: the
embedding cache is a 20 MB .npy the pipeline memory-maps at boot and the
loader re-reads every JSON in the corpus, so per-object latency on a FUSE mount
lands directly on startup time.
The WhatsApp container does the sync for you: set EILAF_CORPUS_S3_URI and it
pulls the store with python -m data.corpus_sync before starting, downloading
only files whose content differs (see app/whatsapp/SETUP.md).
The visualizer (app/visualizer/streamlit_app.py) is the chat UI: pick a
language, pick an answer engine, ask a question. Either way of running it
serves on http://localhost:8501.
Build the corpus first (previous section) — with an empty data/static/enriched/
the app runs but answers nothing.
The LLM heads read their keys from a repo-root .env (already gitignored):
GEMINI_API_KEY=... # gemini engine
CEREBRAS_API_KEY=... # cerebras engine
ANTHROPIC_API_KEY=... # claude engineKeys are resolved lazily, per engine — you only need the one for the engine
you actually pick, and the default echo engine needs none.
The bedrock engine is the exception: it runs the same Claude model as
claude, but through Amazon Bedrock on the project's own AWS account, so it
takes no API key. It needs a region instead, and AWS credentials from the
ambient chain (AWS_PROFILE locally, the EC2 instance role in the deployment —
the same mechanism the report store uses):
EILAF_BEDROCK_REGION=eu-north-1 # required; deliberately has no default
EILAF_BEDROCK_MODEL=... # optional: pin the inference-profile idUnset, the engine stays inert and the others are unaffected. The region has no
default on purpose — botocore would silently fall back to us-east-1 and
generate answers outside the EU. See app/whatsapp/SETUP.md for the IAM policy
and the model-access step, both of which are needed before the engine will
answer.
Run from the repo root (the script path is relative; the corpus dir is resolved from the package itself, so it is found either way):
UV_PROJECT_ENVIRONMENT="${ENVS_DIR:-.}/eilaf" uv run streamlit run app/visualizer/streamlit_app.pyOr activate the env first (source "${ENVS_DIR:-.}/eilaf/bin/activate") and
just streamlit run app/visualizer/streamlit_app.py.
app/visualizer/docker-compose.yml builds the image (context: repo root),
injects the repo-root .env, and publishes port 8501:
cd app/visualizer && docker compose up --buildThe compose file bind-mounts data/static/ (corpus + embedding cache)
read-only and keeps the e5 weights in a named volume, so the model downloads
once and survives restarts. The image installs the CPU build of torch regardless
of the committed CUDA lock — see the comments in app/visualizer/Dockerfile.
Why read-only. The image pins its own copy of the code. A container built before a chunking change computes a different corpus, misses the cache, and — if it could write — would overwrite the host's newer one; the two versions then ping-pong, each start paying a full re-encode. This actually happened on 2026-08-19. Read-only means such a container recomputes in memory instead (slow start, nothing corrupted). Rebuild the image after any change to chunking or the embedder, or every container start pays that cost.
To build/run without compose:
docker build -f app/visualizer/Dockerfile -t eilaf-visualizer . # from the repo root
docker run --rm -p 8501:8501 --env-file .env \
-v "$PWD/data/static:/app/data/static:ro" eilaf-visualizerThe pipeline is built once per Streamlit session, on the first query — the page itself renders immediately.
| Time | Notes | |
|---|---|---|
| Server up (page served) | ~3 s | identical for uv and Docker |
| First query, warm caches | ~7 s | model load + BM25 fit ≈6 s, query ≈1 s (4790 chunks) |
| Later queries | <1 s | pipeline cached by @st.cache_resource |
| Docker image build | ~2 min | ~35 s of that is the CPU-torch uv sync; longer on a first build (base images) |
| First-ever container boot | + model download | ~2.2 GB of e5 weights into the hf-cache volume |
| Cold embedding cache | ~1 h on CPU | only if data/static/enriched/.embeddings is stale or missing |
That last row is the one to watch: without a valid cache the whole corpus is
re-encoded before the first answer (≈0.8 s/chunk on CPU for
multilingual-e5-large, extrapolated from a 64-chunk sample; a GPU or
multilingual-e5-base cuts it sharply). The cache is fingerprinted by corpus
and embedder, so re-running enrichment or swapping the model invalidates it —
budget an hour the next time you rebuild the corpus.
scripts/aws_costs.py prints the actual cost of the infrastructure per
service — the current month, the running total to date, and the change since the
last time you ran it — and shouts if the month is over budget.
./scripts/aws_costs.py # table + append to the CSV
./scripts/aws_costs.py --threshold 25 # tighter budget for this run
./scripts/aws_costs.py --month 2026-08 # look back at a closed month
./scripts/aws_costs.py --since 2024-01 # widen the to-date window
./scripts/aws_costs.py --with-credits # what is billed today instead
./scripts/aws_costs.py --all --no-write # zero-cost services too, look onlyAWS spend by service (UnblendedCost, USD -- actual cost, credits excluded; whole account)
2026-10 month-to-date since 2025-10-01
Service chatbot legacy chatbot legacy change
-------------------------------------------------------------------------------------------------
Amazon Elastic Compute Cloud - Compute 10.00 - 40.00 120.00 0.00
Amazon Virtual Private Cloud 1.00 - 4.00 12.00 0.00
EC2 - Other 1.00 - 4.00 10.00 0.00
AWS Budgets - 0.10 - 1.00 0.00
nothing in 2026-10 -- to date only chatbot legacy $20.00 in total
Amazon Registrar - 16.00
Tax - 3.00
Amazon Simple Storage Service - 1.00
-------------------------------------------------------------------------------------------------
TOTAL 12.00 0.10 48.00 163.00 0.00
Both halves together: 2026-10 $12.10, since 2025-10-01 $211.00.
(Figures above are illustrative. Real ones live in the git-ignored CSV.)
Every run splits the cost in two, so each service is one row rather than the
same name appearing in two tables for the reader to join. chatbot is
eu-north-1 from 2026-09; legacy is everything else in scope.
Services that stopped billing move to a second block with only the two
columns that can say anything about them. Most of this account's services are
dormant history, so keeping them in the main table meant four money columns that
were mostly blank. A hard zero prints as - for the same reason: a column of
0.00s competes with the figures that matter. --all adds the services that
have never cost anything at all.
Every run splits the cost in two, so each service is one row rather than the
same name appearing in two tables for the reader to join. chatbot is
eu-north-1 from 2026-09; legacy is everything else in scope. A hard zero
prints as -, because a column of 0.00s competes with the figures that matter.
Credits and refunds are excluded, which on this account is the difference
between a useful number and a useless one: it runs on a promotional credit
balance, so every Usage line is cancelled by an equal Credit line and the
netted total reads $0.00 while the infrastructure still costs real money. A
budget alarm on the netted figure would be unreachable until the credits ran dry
— exactly when it is needed. --with-credits asks the other question, what the
card is charged today; being a look rather than a reading, it records nothing.
Dropping credits is also what makes a service-grouped table honest. AWS files a
credit against a different service than the charge it cancels — here the
transfer usage books under EC2 - Other and its credit under AWS Data Transfer — so a netted table shows one service as purely positive and another
as purely negative, neither of which is a thing that happened.
The exclusion works by dropping record types (Credit, Refund) rather than
allowlisting Usage and Tax: AWS books real charges under several other types
too (DiscountedUsage, SavingsPlanCoveredUsage, Support), and an allowlist
would discard them silently — the one failure mode a cost guard must not have.
Discount records stay for the same reason: a negotiated rate is part of the
real price, where a promotional credit is a balance draining.
Everything comes from one Cost Explorer request — monthly granularity over a
wide window, so the current month is the last bucket and the to-date total is the
sum of them all; grouped by RECORD_TYPE as well as by service, which is what
separates the credit records from the charges. Neither the extra column nor the
second group-by costs anything, which matters because the request is the
expensive part. It does page when the two group-bys produce more groups than one
response holds, and each extra page is another $0.01 — a truncated answer being
worse than a dearer one.
"To date" means the queried window, not the account's whole life: Cost Explorer
serves 12 months by default, which is what this asks for. Widen it with
--since if the account is older and has historical data enabled.
Each run appends a snapshot to data/costs/aws_costs.csv (git-ignored — see
.gitignore) as
snapshot,scope,month,service,chatbot_month_usd,legacy_month_usd,
chatbot_to_date_usd,legacy_to_date_usd,delta_usd. That
file is the "change since" column, and the diff is taken on the to-date
figure rather than the month's — a to-date total only ever grows, so the delta
stays meaningful across a month boundary instead of resetting to a misleading
negative on the 1st. A file written by an older column layout is migrated in
place on the next write, with the actual-cost cells left blank on its rows
rather than back-filled: those figures were netted against credits, and copying
them across would read as "no credits that month", inventing history. The first
run after such a migration therefore shows - for the change rather than a
jump the size of the credit balance.
The budget defaults to $40, overridable per-run with --threshold or
per-machine with EILAF_COST_ALERT_USD. Over it, the script prints a banner to
stderr and exits 2 (1 = the query failed, 0 = under budget), so a cron job or
&& chain can act on it. The alert fires on the month, not on the to-date
total, deliberately: a lifetime figure crosses any fixed line exactly once and
then screams forever, whereas a monthly budget re-arms itself. It watches the
figure the table shows — the actual cost — so it cannot sit silent behind a
credit balance.
This AWS account carries workloads older than this project, and they dominate
the history — most of the twelve-month total is theirs. The split is a
region-and-month rule (CHATBOT_REGION, CHATBOT_SINCE at the top of the script), which is
exact here by luck of timing: the chatbot's instance launched 2026-09-12 into
eu-north-1, and the earlier workload in that region had already stopped — by
the month before the launch it had dwindled to pennies.
The rule is a heuristic, and it is worth knowing where it frays: global and
NoRegion costs (a domain, Route 53, tax) always land in legacy whether or not
they serve this project, and a stray $0.01 of RDS in eu-north-1 falls inside
the window without being the chatbot's.
For an exact split, tag the resources and filter on the tag:
- Tag the chatbot's resources — instance, its EBS volume, the reports bucket —
with something like
Project=eilaf-chatbot. - Activate
Projectin Billing > Cost allocation tags (takes up to 24 h). ./scripts/aws_costs.py --tag Project=eilaf-chatbot
Tags are not retroactive: they cover only usage incurred after activation, so the region-and-month rule stays the way to read the history. An empty tagged result says so rather than implying the resources were free.
--region and --tag narrow what is counted at all; the chatbot/legacy split
then applies within whatever is left. Each scope keeps its own history in
the CSV's scope column, and a snapshot is only ever diffed against its own
scope — a narrowed reading measured against a whole-account one would otherwise
look like a huge saving. An explicit --since is part of the scope for the same
reason; the default rolling window is not, so the main series stays continuous.
--month only chooses which bucket the per-month column reports; the to-date
column always runs to now. A back-dated --month prints but records nothing, so
a look backwards can't become the baseline the next run diffs against.
Three caveats, all of them load-bearing:
- The query costs $0.01. Cost Explorer bills per
GetCostAndUsagerequest and this makes one per run — fine daily (~$0.30/month), absurd every five minutes ($86/month, more than the budget it guards). - Cost Explorer needs a permission the report user lacks by default. The
eilafprofile in~/.awsiseilaf-dev-reports, whose policy otherwise reaches S3 and only S3 (theconversations/prefix of the reports bucket, seeapp/whatsapp/SETUP.md). Add ace:GetCostAndUsagestatement — the script prints it when denied. Two one-time account settings also gate billing data: Cost Explorer has to be enabled (Billing console; first activation takes up to 24 h to backfill), and IAM access to Billing has to be on (Account settings), without which an IAM user is denied even holding the action. - The numbers lag. Cost Explorer refreshes a few times a day, so the current
month is always incomplete and is flagged
Estimated. It is a trend gauge; the bill is the bill.
A negative net line is normal, not an error: a credit need not post against
the service whose charge it cancels. On this account the always-free 100 GB/month
of outbound transfer shows as an EC2 - Other charge and an equal negative AWS Data Transfer credit, which is why net can show per-service activity and still
total nothing. Gross keeps the charge and drops the credit, so it does not cancel
out the same way.
Note --profile defaults to eilaf (else $AWS_PROFILE) because ~/.aws here
holds no default profile. Cost Explorer is queried in us-east-1 regardless of
EILAF_REPORT_REGION — it is a global service with one endpoint.