镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
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
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CI

on:
push:
branches: [master]
pull_request:

permissions:
contents: read

jobs:
check:
name: Lint, types, tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v6
with:
# Same uv as the Dockerfile, so CI resolves what production builds
version: "0.10.11"
enable-cache: true

- name: Install
run: uv sync --locked --dev

- name: Format
run: uv run ruff format --check .

- name: Lint
run: uv run ruff check .

- name: Type check
run: uv run ty check

- name: Script syntax
run: node --check static/script.js

- name: Tests
run: uv run pytest --cov=app --cov-report=term-missing --cov-fail-under=95

docker:
name: Docker image builds
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: docker build --tag iris:ci .
21 changes: 21 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Runs the same checks as CI, with the tool versions pinned in uv.lock.
# Install once with: uvx pre-commit install
repos:
- repo: local
hooks:
- id: ruff-format
name: ruff format
entry: uv run ruff format
language: system
types: [python]
- id: ruff-check
name: ruff check
entry: uv run ruff check --fix
language: system
types: [python]
- id: ty
name: ty check
entry: uv run ty check
language: system
types: [python]
pass_filenames: false
17 changes: 14 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,10 @@ The page is flat. Depth is conveyed by tone (Raised Ground for chips) and by the
### Shadow Vocabulary
- **Focus ring** (`box-shadow: 0 0 0 2px #0d1117, 0 0 0 4px #58a6ff`): every `:focus-visible`; inputs instead swap their underline to blue with a 1px blue under-shadow.
- **Knock-out ring** (`box-shadow: 0 0 0 3px #0d1117`): step marks, so the rail appears to pass behind them.
- **Tooltip lift** (`box-shadow: 0 8px 24px rgba(1, 4, 9, 0.6)`): the graph tooltip only.
- **Tooltip lift** (`box-shadow: 0 8px 24px rgba(1, 4, 9, 0.6)`): the graph tooltip and the title suggestion list, the two surfaces that float over other content.

### Named Rules
**The Flat Ground Rule.** Nothing on the page casts a shadow except the pointer tooltip. Do not lift sections, buttons or chips.
**The Flat Ground Rule.** Nothing on the page casts a shadow except what floats above it: the pointer tooltip and the suggestion list. Do not lift sections, buttons or chips.

## Shapes

Expand All @@ -242,6 +242,12 @@ Restrained and exact: one filled action, everything else outlined or bare.
- **Hover:** underline lifts to Faint Ink.
- **Error:** `aria-invalid="true"` turns the underline To Red with the same 1px under-shadow.

### Suggestions
A listbox hung from the bottom of a route field while typing (ARIA combobox; the input keeps focus). Raised Ground with a 1px Strong Hairline border, no top border so it reads as the field's continuation, square corners and the tooltip lift. Each option is a 44px row ruled by a Hairline: the title at Body 500, the Wikipedia short description below in Label Faint Ink. The active option takes Sunken Fill and a Path Blue title. A disambiguation page in the To field is muted and says to pick a specific article.

### Examples
Under the route actions, one Small line: "Try" in Faint Ink, three example routes as Path Blue in-text actions (shuffled per visit), and "Surprise me" in Muted Ink. Each is a 44px target.

### Notice
The error and "stopped" message, ruled not boxed. A three-column row: 20px stroked icon in the tone colour, body, actions. A tone rule on top (the tone at 55% via `color-mix`), a Hairline below, 16px vertical padding, no fill, no radius. The title names the problem (To Red Ink, 600), the message adds a new fact (Ink, Small), the hint gives recovery (Muted Ink, Small); empty lines collapse. The neutral variant swaps the tone to Faint Ink and the title to Ink. It enters with a 4px drop-in over 0.35s.

Expand All @@ -252,13 +258,18 @@ The masthead is a 64px bar ruled below: the wordmark (24px logo plus "Iris" at T
The signature of the instrument: a row of figures separated by vertical Hairlines, no boxes. Four equal columns plus a double-width "Reading" column holding the page currently being read as a Path Blue link (Faint Ink and inert when empty). Labels are 12px Faint Ink; values are Reading type. Depth is shown as a row of 8px circles: hollow (Strong Hairline), active (blue outline, beating), completed (blue fill). Above the readings a 1px activity track carries a blue sweep while searching.

### The Field (graph plot)
A dot field (1px dots at 16% grey on a 20px grid) framed by corner registration marks, 360px minimum height (300px on phones). While searching it shows only the two ends as label chips joined by a dashed 1px line with a travelling blue highlight; when the path arrives the D3 graph replaces it. Nodes are blue with a 2px ground stroke; the start and end nodes take their signal colour with a translucent 5px halo of the same hue. Path edges are 2.5px Path Blue with a single SVG arrowhead; other edges are 2px Strong Hairline at 60%. Label chips are Raised Ground with a 1px Strong Hairline border and 4px corners.
A dot field (1px dots at 16% grey on a 20px grid) framed by corner registration marks, 360px minimum height (300px on phones). While searching it shows the two frontiers: each end as a label chip, under it the pages that side read last (Label size, newest first in Ink, older ones fading to 26%), and a count ("214 pages · 2 links out") in Faint Ink. Between them a dashed 1px line carries a travelling blue highlight, and each side fills the line with solid Path Blue from its own end as it goes deeper, scaled to the whole depth budget so the two fills meet mid-line. Under 640px the scene stacks: start on top, end at the bottom reading upward, three pages per side. When the path arrives the D3 graph replaces it. Nodes are blue with a 2px ground stroke; the start and end nodes take their signal colour with a translucent 5px halo of the same hue. Path edges are 2.5px Path Blue with a single SVG arrowhead; other edges are 2px Strong Hairline at 60%. Label chips are Raised Ground with a 1px Strong Hairline border and 4px corners.

The force simulation is tuned and fixed: link distance spans the available axis with strength 1; charge -240 (horizontal) or -160 (vertical); x/y position forces 0.22/0.10 (horizontal) or 0.16/0.22 (vertical); collision strength 0.8; alphaDecay 0.03; velocityDecay 0.45. These values are part of the system, not tuning knobs.

### Route List
The found route as a ruled ordered list: each step is a 52px row ruled on top, with a 9px mark, a 36px index column, the title and a stroked open-arrow. A 1px blue rail at 45% opacity threads the marks, starting at the first mark and ending at the last; first mark green, last red, the rest blue. Hover turns the title Path Blue and nudges the arrow 2px up-right.

Above the list, the "Route" title shares its row with a "Copy link" text button (the system share sheet on touch devices). Under the summary, one Small Muted sentence explains the search: pages read on each side and where the frontiers met; that step carries a Faint "Frontiers met" tag. Under every step but the last hangs the sentence from that article which links to the next, as a quotation in Small Muted Ink aligned with the titles, the linked words in Path Blue and the section name in Faint Ink. A link found only in an infobox or navigation box says so in Faint Ink instead.

### 404
The same shell, masthead and colophon. The lead carries "404" at Reading size in To Red Ink, the display line "No page here.", one line of copy and the primary button back to the search.

## Do's and Don'ts

### Do:
Expand Down
12 changes: 4 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
FROM python:3.13-slim

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Pinned so a uv release can never change a build that the repo did not
COPY --from=ghcr.io/astral-sh/uv:0.10.11 /uv /uvx /bin/

WORKDIR /app

# Install Python dependencies (production only)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

# Copy application code
# Copy application code (entrypoint.sh is executable in git)
COPY . .

# Copy and set up entrypoint
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# Make the venv's binaries available (gunicorn, celery, etc.)
ENV PATH="/app/.venv/bin:$PATH"

# SERVICE_TYPE=worker runs celery, anything else runs gunicorn
CMD ["/entrypoint.sh"]
CMD ["/app/entrypoint.sh"]
4 changes: 2 additions & 2 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The implementation is itself part of the point. The project stands as a demonstr

## Operating Context

- Single page, no accounts, no persistence beyond the browser. A visitor types two titles and presses one button.
- Single page, no accounts. A visitor types two titles (with suggestions) or picks an example, and presses one button. A found route is kept for thirty days so a shared link can show it.
- Searches run as background Celery tasks and are polled by the UI; a search survives a page refresh because the latest one is kept in `localStorage`.
- Deployed publicly at `iris.hishaam.dev` (Railway, Docker; `entrypoint.sh` switches role on `SERVICE_TYPE`). It is live but not actively promoted, so traffic is incidental and stakes are low — strangers can still arrive cold with no context.
- A run takes seconds to a couple of minutes. Waiting is a normal part of the experience, not an error state.
Expand All @@ -46,7 +46,7 @@ The implementation is itself part of the point. The project stands as a demonstr
## Brand Commitments

- Name: **Iris**; full title "Iris: Wikipedia Path Finder".
- Existing identity assets, all in `static/`: `logo.svg`, `favicon-dark.svg` / `favicon-light.svg` (switched on `prefers-color-scheme`), `og-image.svg` / `og-image.png`.
- Existing identity assets, all in `static/`: `logo.svg`, `favicon.svg` (its lines follow `prefers-color-scheme` from inside the SVG), `favicon.ico`, `apple-touch-icon.png`, `icon-192.png` / `icon-512.png` (web manifest), `og-image.svg` / `og-image.png`.
- The GitHub-dark palette and JetBrains Mono are binding: the README and OG image are built on them, so a change to the theme is a change to published assets.
- GPL-3.0 licensed (see `LICENSE`), open source at `github.com/mdhishaamakhtar/iris`.
- Built by Md Hishaam Akhtar and Sharanya Mukherjee under DSC VIT; the README carries that attribution.
Expand Down
83 changes: 79 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

## Iris Wikipedia Pathfinder

[![CI](https://github.com/mdhishaamakhtar/iris/actions/workflows/ci.yml/badge.svg)](https://github.com/mdhishaamakhtar/iris/actions/workflows/ci.yml)

Iris finds a path between any two Wikipedia pages by traversing their links.

You give it two page titles — say, "Banana" and "Shah Rukh Khan" — and it walks Wikipedia's link graph using BFS until it finds the connection. Results are visualized as an interactive graph you can drag around.
Expand All @@ -21,9 +23,77 @@ It uses **bidirectional BFS** (default) which searches simultaneously from both
## What It Does

- Find Wikipedia paths via **bidirectional BFS** (default) or standard forward-only BFS
- Async task processing — searches run in the background, results polled live
- Real-time progress updates during search (aggregated from both search frontiers)
- Interactive D3.js graph visualization of the path
- **Watch both frontiers grow** — each side shows the pages it is reading and how deep it has gone, streamed over Server-Sent Events
- **Every step explained** — the route quotes the sentence in each article that links to the next one
- **Shareable routes** — `/?from=Banana&to=Shah%20Rukh%20Khan` opens (or runs) that search, and unfurls in Slack, Discord or a social feed as an image of the route
- **Title suggestions** as you type, with disambiguation pages flagged before they fail
- Interactive D3.js graph of the path, and a ruled route list

## How It Works

```mermaid
flowchart LR
B[Browser] -- "POST /getPath" --> W[Flask / Gunicorn]
W -- "queue task" --> R[(Redis)]
R -- "task" --> C[Celery worker]
C -- "links + backlinks" --> WP[Wikipedia API]
C -- "frontiers, progress, caches" --> R
W -- "GET /tasks/stream/id (SSE)" --> B
R -- "task state" --> W
```

The web process never searches. It queues a Celery task and streams the task's
state back to the browser. The worker runs one breadth-first search over one
or two *frontiers*: forward from the start page through its links, and backward
from the end page through its backlinks, always expanding whichever side is
smaller. When a page turns up in both, the path is read back out of the parent
pointers. Every piece of search state — queues, visited sets, parent pointers —
lives in Redis, namespaced per search and deleted when it ends.

Link lists are cached in Redis. Hub articles (500+ links: countries, years,
broad topics) are cached for a week rather than a day, because nearly every
search passes through them.

The route found is the first one, not a proven shortest: Wikipedia caps how
many links one request returns, and Iris follows at most three continuations
per article.

## Performance

Search time is Wikipedia's request latency times the number of requests in
flight, not computation. Measured with `scripts/benchmark.py` on 24 October
2026: bidirectional search, production settings, a cold cache, one run each,
from a developer laptop.

| Route | Time | Wikipedia requests | Pages explored | Path |
| --- | ---: | ---: | ---: | --- |
| Pizza → Byzantine Empire | 0.97 s | 2 | 1 | 2 pages |
| Mount Everest → Jazz | 2.79 s | 6 | 2 | 3 pages |
| Kangaroo → Fourier transform | 5.44 s | 33 | 22 | 4 pages |
| Pikachu → Quantum chromodynamics | 5.93 s | 37 | 22 | 4 pages |
| Taylor Swift → Photosynthesis | 7.37 s | 40 | 22 | 4 pages |
| Barack Obama → Mathematics | 8.70 s | 54 | 22 | 4 pages |

A repeated search is answered from the path cache in milliseconds. Run
`uv run python scripts/benchmark.py` (needs local Redis and network) to
measure your own; `--compare` prints before/after.

## API

| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/getPath` | Start a search: `{start, end, algorithm?}` → `202` with `task_id` |
| `GET` | `/tasks/stream/<id>` | Progress and result as Server-Sent Events |
| `GET` | `/tasks/status/<id>` | The same status body, polled |
| `DELETE` | `/tasks/<id>` | Stop a search |
| `GET` | `/api/suggest?q=` | Title suggestions |
| `GET` | `/api/context?from=&to=` | The sentence in one article that links to the next |
| `GET` | `/api/og.png?from=&to=` | Share image for a route |
| `GET` | `/health` | Redis connectivity |

Full schemas are in Swagger at `/api/docs`. The API is public and
unauthenticated, so it is rate-limited per client: 10 searches a minute, 120
requests a minute to the lighter endpoints (`429` with `Retry-After` beyond).

## Tech Stack

Expand Down Expand Up @@ -58,7 +128,7 @@ Swagger docs at [http://localhost:9020/api/docs](http://localhost:9020/api/docs)
| Script | When to use |
|--------|-------------|
| `dev.sh` | Local development — starts Redis (if needed), Flask, and Celery in one terminal |
| `entrypoint.sh` | Docker/Railway container entry — switches on `SERVICE_TYPE` env var |
| `entrypoint.sh` | The Docker image's `CMD` — runs Gunicorn, or Celery when `SERVICE_TYPE=worker` |

## Dev

Expand All @@ -69,8 +139,13 @@ uv run pytest --cov=app --cov-report=html # generate HTML coverage re
uv run ruff format . # format
uv run ruff check . # lint
uv run ty check # type check
uvx pre-commit install # run format, lint and types on every commit
```

CI runs the same checks on every push and pull request, requires 95% test
coverage, and builds the Docker image. Tests run against `fakeredis` with only
Wikipedia stubbed, so the search, the Redis store and the HTTP API run for real.

## Contributors

- **Md Hishaam Akhtar** — [GitHub](https://github.com/mdhishaamakhtar) · [LinkedIn](https://www.linkedin.com/in/md-hishaam-akhtar-812a3019a/)
Expand Down
11 changes: 10 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from flask import Flask, Response, current_app, g, jsonify, request
from marshmallow import ValidationError
from werkzeug.exceptions import HTTPException
from werkzeug.middleware.proxy_fix import ProxyFix

from app.config import Settings
from app.errors import ErrorCode, IrisError
Expand Down Expand Up @@ -77,6 +78,10 @@ def create_app(settings: Settings | None = None) -> Flask:
settings = settings or Settings.from_env()

app = Flask(__name__, static_folder="../static", static_url_path="/static")
# Railway terminates TLS in front of Gunicorn. Trusting one proxy hop is
# what makes request.remote_addr the client (for rate limits) and
# request.url_root https (for absolute share-preview URLs).
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # type: ignore[method-assign]
app.config.update(
SECRET_KEY=settings.secret_key,
DEBUG=settings.debug,
Expand Down Expand Up @@ -119,6 +124,8 @@ def finish_request(response: Response) -> Response:
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
if request.path.startswith(("/static/", "/flasgger_static/")):
return response
logger.info("request_completed", extra={"http_status": response.status_code})
Expand Down Expand Up @@ -150,7 +157,9 @@ def on_iris_error(exc: IrisError):
logger.warning(
"application_error", extra={"code": exc.code, "error": exc.message}
)
return problem(exc.message, exc.code, exc.status)
response, status = problem(exc.message, exc.code, exc.status)
response.headers.update(exc.headers)
return response, status

@app.errorhandler(HTTPException)
def on_http_error(exc: HTTPException):
Expand Down
26 changes: 26 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,36 @@ class Settings:

# Cache lifetimes, in seconds
links_cache_ttl: int = 86_400
hub_cache_ttl: int = 604_800
"""Link lists that reach ``hub_link_threshold`` belong to hub articles
(countries, years, broad topics). Nearly every search passes through them
and their link sets barely move, so they are kept a week, not a day."""
hub_link_threshold: int = 500
path_cache_ttl: int = 3_600
page_cache_ttl: int = 7_200
search_state_ttl: int = 3_600
"""Safety net so per-search Redis keys expire even if cleanup is skipped."""
share_ttl: int = 2_592_000
"""How long a found route stays available to shared links and their
preview images: thirty days, long after the path cache has let it go."""
context_cache_ttl: int = 604_800
suggest_cache_ttl: int = 86_400

# Rate limits, per client IP per window
search_rate_limit: int = 10
"""Searches started per window. Each one fans out to hundreds of
Wikipedia requests, so this is what keeps one script from getting the
whole service throttled."""
api_rate_limit: int = 120
"""Requests per window to the lighter endpoints (suggestions, link
context, progress streams, share images)."""
rate_window: int = 60

# Progress streaming
stream_interval: float = 0.5
stream_max_seconds: int = 120
"""A stream closes after this long and the browser reconnects, so a
stream for a task that never starts cannot hold a thread forever."""

# Pathfinding
max_search_depth: int = 6
Expand Down
Loading
Loading