Skip to content

Commit e60316b

Browse files
jlhe97claude
andauthored
Step 6: end-to-end pipeline with name→handle resolution and confidence flags
- pipeline/resolver.py: resolve_lichess() and resolve_chesscom() return (username, confidence) using SequenceMatcher similarity for Lichess and guess-attempt index for chess.com - pipeline/runner.py: run_pipeline() orchestrates scrape → resolve → fetch → build_dossier → write per-player .md and combined.md - dossier/report.py: low-confidence profile matches flagged with warning - tests/test_pipeline.py: 24 tests covering resolver, runner, and confidence flag rendering (all mocked, no network) - README: Step 6 usage docs and roadmap updated - CLAUDE.md: pipeline package architecture documented https://claude.ai/code/session_01VQfqug9MDEyRFydkmES4n2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0307fae commit e60316b

7 files changed

Lines changed: 589 additions & 7 deletions

File tree

CLAUDE.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ megabase/ → one-time SQLite index of ChessBase PGN export → game PG
2828
lookup/ → Lichess + chess.com API → online profiles + game PGNs
2929
analysis/ → PGN strings → opening repertoire + tendency stats
3030
dossier/ → all of the above → rendered Markdown/JSON report
31+
pipeline/ → end-to-end orchestrator: tournament → dossier folder
32+
resolver.py → name → (username, confidence) for Lichess and chess.com
33+
runner.py → run_pipeline(): scrape → resolve → fetch → build → write
3134
```
3235

3336
### Data flow
@@ -56,8 +59,19 @@ dossier/ → all of the above → rendered Markdown/JSON report
5659

5760
Full URLs are auto-detected; `--site` is only needed for ID shorthands.
5861

62+
### Step 6 pipeline details
63+
64+
`pipeline/resolver.py`:
65+
- `_similarity(a, b)` — case-insensitive `SequenceMatcher` ratio on normalised strings
66+
- `resolve_lichess(name)``(username, "high"|"low"|None)` — calls `lookup.lichess.search()`, scores top result against player name; `>=0.55` → high, `>=0.30` → low
67+
- `resolve_chesscom(name)``(username, "high"|"low"|None)` — tries `guess_usernames()` patterns; first 2 hits → high, later → low
68+
69+
`pipeline/runner.py`:
70+
- `run_pipeline(tournament, ...)` — full orchestration; returns `list[Path]` of written files
71+
- Writes `<output_dir>/<slug>.md` per player and `combined.md` in markdown mode
72+
- Low-confidence profiles get `"confidence": "low"` injected before being passed to `build_dossier()`
73+
5974
### Roadmap
6075

61-
- Steps 1–5 are complete (scraping, megabase indexing, online lookup, analysis, dossier generation).
62-
- Step 6 (end-to-end pipeline): tournament URL → auto-resolve Lichess/chess.com handles → fetch games → generate all dossiers as a folder of Markdown files + combined PDF. Name→handle resolution picks the best autocomplete candidate and flags low-confidence matches in the report.
63-
- MegaDatabase integration will be added to Step 6 once the SQLite index is built.
76+
- Steps 1–6 are complete.
77+
- Remaining: MegaDatabase integration into Step 6, combined PDF output.

README.md

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,15 +303,70 @@ dossier = build_dossier("Smith, John", pgn_strings, profiles=profiles)
303303
print(render_markdown(dossier))
304304
```
305305

306+
## Step 6 — End-to-end pipeline
307+
308+
One command turns a tournament URL into a folder of per-opponent dossiers.
309+
310+
```bash
311+
# By tournament ID (kingregistration.com, default)
312+
python -m pipeline.runner Challenge34
313+
314+
# By full URL (site auto-detected)
315+
python -m pipeline.runner "https://chessaction.com/tournaments/advance_entry_list.php?tid=nKGioA=="
316+
317+
# Custom output directory and game limits
318+
python -m pipeline.runner Challenge34 --output-dir ./dossiers --max-games 30 --chesscom-months 6
319+
320+
# JSON output (no combined.md)
321+
python -m pipeline.runner Challenge34 --format json
322+
```
323+
324+
**All flags**
325+
```
326+
python -m pipeline.runner <tournament>
327+
[--site kingregistration|chessaction]
328+
[--output-dir DIR] default: dossiers/
329+
[--max-games N] Lichess games to fetch per player (default: 50)
330+
[--chesscom-months N] chess.com history window in months (default: 3)
331+
[--depth N] opening depth in half-moves (default: 6)
332+
[--top N] top N opening lines per colour (default: 8)
333+
[--format markdown|json] output format (default: markdown)
334+
```
335+
336+
**Output**
337+
```
338+
dossiers/
339+
smith_john.md ← one file per opponent
340+
doe_jane.md
341+
combined.md ← all dossiers concatenated (markdown mode only)
342+
```
343+
344+
Low-confidence name→handle matches are flagged in the report:
345+
```
346+
## Online Profiles
347+
- **Lichess**: [xyz99](https://lichess.org/@/xyz99) ⚠️ *low-confidence match*
348+
```
349+
350+
### Python API
351+
352+
```python
353+
from pipeline.runner import run_pipeline
354+
355+
paths = run_pipeline("Challenge34", output_dir="dossiers", max_games=50)
356+
# returns list of Path objects for written files
357+
```
358+
306359
## Roadmap
307360

308361
- [x] Step 1 — Scrape tournament entry lists (kingregistration, chessaction)
309362
- [x] Step 2 — Index ChessBase MegaDatabase for fast player lookups
310363
- [x] Step 3 — Look up each player on Lichess and chess.com
311364
- [x] Step 4 — Analyse openings and tendencies
312365
- [x] Step 5 — Generate per-opponent dossier report
313-
- [ ] Step 6 — End-to-end pipeline
366+
- [x] Step 6 — End-to-end pipeline
314367
- Single command: tournament URL → dossiers for every opponent
315368
- Name → handle resolver: Lichess autocomplete + chess.com guesser, pick best candidate automatically; flag low-confidence matches in the report
316-
- Fetch games from MegaDatabase index + Lichess + chess.com and merge
317-
- Output: folder of Markdown files (one per opponent) + single combined PDF
369+
- Fetches games from Lichess and chess.com and merges into a single dossier
370+
- Output: folder of Markdown files (one per opponent) + `combined.md`
371+
- [ ] MegaDatabase integration (once SQLite index is built)
372+
- [ ] Combined PDF output

dossier/report.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ def render_markdown(dossier: dict) -> str:
7979
f"{k.capitalize()}: {v}" for k, v in p.get("ratings", {}).items()
8080
)
8181
title = f"{p['title']} " if p.get("title") else ""
82+
flag = " ⚠️ *low-confidence match*" if p.get("confidence") == "low" else ""
8283
lines.append(f"- **{site}**: [{title}{p['display_name']}]({p['url']})"
83-
+ (f" — {ratings}" if ratings else ""))
84+
+ (f" — {ratings}" if ratings else "") + flag)
8485
lines.append("")
8586

8687
# --- Overview ---

pipeline/__init__.py

Whitespace-only changes.

pipeline/resolver.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""
2+
Resolve a player's real name (from a tournament entry list) to their
3+
Lichess and chess.com usernames.
4+
5+
Strategy:
6+
Lichess — autocomplete search; pick the top result; confidence is
7+
determined by fuzzy name similarity.
8+
chess.com — try guessed username patterns in order; confidence is
9+
high if an early guess matches, low if a later one does.
10+
11+
Confidence levels: "high" | "low"
12+
"""
13+
14+
from difflib import SequenceMatcher
15+
16+
import requests
17+
18+
19+
_HIGH_THRESHOLD = 0.55 # similarity ratio for high confidence
20+
_LOW_THRESHOLD = 0.30 # below this → skip
21+
22+
23+
def _similarity(a: str, b: str) -> float:
24+
"""Case-insensitive character similarity between two name strings."""
25+
def norm(s: str) -> str:
26+
return "".join(s.lower().split()).replace(",", "").replace(".", "")
27+
return SequenceMatcher(None, norm(a), norm(b)).ratio()
28+
29+
30+
def resolve_lichess(name: str) -> tuple[str | None, str | None]:
31+
"""
32+
Search Lichess by display name. Returns (username, confidence) or (None, None).
33+
Imports lookup.lichess lazily so resolver is testable without network.
34+
"""
35+
try:
36+
from lookup.lichess import search
37+
candidates = search(name, max_results=5)
38+
except Exception:
39+
return None, None
40+
41+
if not candidates:
42+
return None, None
43+
44+
best = candidates[0]
45+
sim = _similarity(name, best.get("display_name", ""))
46+
47+
if sim >= _HIGH_THRESHOLD:
48+
return best["username"], "high"
49+
if sim >= _LOW_THRESHOLD:
50+
return best["username"], "low"
51+
return None, None
52+
53+
54+
def resolve_chesscom(name: str) -> tuple[str | None, str | None]:
55+
"""
56+
Try guessed chess.com usernames in order. Returns (username, confidence)
57+
or (None, None). First two guesses → high confidence, later → low.
58+
"""
59+
try:
60+
from lookup.chesscom import guess_usernames, get_profile
61+
except Exception:
62+
return None, None
63+
64+
guesses = guess_usernames(name)
65+
for i, username in enumerate(guesses):
66+
try:
67+
get_profile(username)
68+
confidence = "high" if i < 2 else "low"
69+
return username, confidence
70+
except (requests.HTTPError, requests.ConnectionError):
71+
continue
72+
return None, None

pipeline/runner.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""
2+
End-to-end pipeline: tournament URL → per-opponent dossiers.
3+
4+
Usage:
5+
python -m pipeline.runner Challenge34
6+
python -m pipeline.runner Challenge34 --site kingregistration --output-dir ./dossiers
7+
python -m pipeline.runner "https://chessaction.com/tournaments/advance_entry_list.php?tid=nKGioA=="
8+
python -m pipeline.runner Challenge34 --max-games 30 --format json
9+
10+
Output (default):
11+
<output-dir>/
12+
smith_john.md ← one file per opponent
13+
combined.md ← all dossiers concatenated (print-friendly)
14+
"""
15+
16+
import re
17+
import sys
18+
import argparse
19+
from pathlib import Path
20+
21+
from scraper import scrape_entry_list
22+
from dossier.report import build_dossier, render_markdown, render_json
23+
from pipeline.resolver import resolve_lichess, resolve_chesscom
24+
25+
26+
def _slug(name: str) -> str:
27+
"""'Smith, John' → 'smith_john'"""
28+
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
29+
30+
31+
def _fetch_lichess_games(username: str, max_games: int) -> tuple[list[str], dict | None]:
32+
try:
33+
from lookup.lichess import get_profile, games_as_pgn
34+
import io, chess.pgn, time
35+
profile = get_profile(username)
36+
time.sleep(1.0)
37+
pgn_text = games_as_pgn(username, max=max_games)
38+
pgns = []
39+
buf = io.StringIO(pgn_text)
40+
while True:
41+
game = chess.pgn.read_game(buf)
42+
if game is None:
43+
break
44+
import io as _io
45+
out = _io.StringIO()
46+
game.accept(chess.pgn.FileExporter(out))
47+
pgns.append(out.getvalue().strip())
48+
return pgns, profile
49+
except Exception as exc:
50+
print(f" Lichess fetch failed ({username}): {exc}", file=sys.stderr)
51+
return [], None
52+
53+
54+
def _fetch_chesscom_games(username: str, months: int) -> tuple[list[str], dict | None]:
55+
try:
56+
from lookup.chesscom import get_profile, games_as_pgn
57+
import re as _re
58+
profile = get_profile(username)
59+
pgn_text = games_as_pgn(username, months=months)
60+
pgns = [g for g in _re.split(r"\n(?=\[)", pgn_text.strip()) if g.strip()]
61+
return pgns, profile
62+
except Exception as exc:
63+
print(f" chess.com fetch failed ({username}): {exc}", file=sys.stderr)
64+
return [], None
65+
66+
67+
def run_pipeline(
68+
tournament: str,
69+
site: str = "kingregistration",
70+
output_dir: str = "dossiers",
71+
max_games: int = 50,
72+
chesscom_months: int = 3,
73+
depth: int = 6,
74+
top: int = 8,
75+
fmt: str = "markdown",
76+
) -> list[Path]:
77+
"""
78+
Run the full pipeline for a tournament. Returns list of written file paths.
79+
"""
80+
out = Path(output_dir)
81+
out.mkdir(parents=True, exist_ok=True)
82+
83+
print(f"Scraping entry list: {tournament}", file=sys.stderr)
84+
players = scrape_entry_list(tournament, site=site)
85+
if not players:
86+
print("No players found — check the tournament URL.", file=sys.stderr)
87+
return []
88+
print(f"Found {len(players)} player(s).", file=sys.stderr)
89+
90+
written: list[Path] = []
91+
combined_parts: list[str] = []
92+
93+
for i, player in enumerate(players, 1):
94+
name = player.get("name", "").strip()
95+
if not name:
96+
continue
97+
98+
print(f"\n[{i}/{len(players)}] {name}", file=sys.stderr)
99+
100+
pgn_strings: list[str] = []
101+
profiles: list[dict] = []
102+
103+
# --- Lichess ---
104+
lichess_user, lc_conf = resolve_lichess(name)
105+
if lichess_user:
106+
print(f" Lichess: {lichess_user} ({lc_conf} confidence)", file=sys.stderr)
107+
pgns, profile = _fetch_lichess_games(lichess_user, max_games)
108+
print(f" Lichess games: {len(pgns)}", file=sys.stderr)
109+
pgn_strings += pgns
110+
if profile:
111+
profiles.append({**profile, "confidence": lc_conf})
112+
else:
113+
print(" Lichess: no match found", file=sys.stderr)
114+
115+
# --- chess.com ---
116+
cc_user, cc_conf = resolve_chesscom(name)
117+
if cc_user:
118+
print(f" chess.com: {cc_user} ({cc_conf} confidence)", file=sys.stderr)
119+
pgns, profile = _fetch_chesscom_games(cc_user, chesscom_months)
120+
print(f" chess.com games: {len(pgns)}", file=sys.stderr)
121+
pgn_strings += pgns
122+
if profile:
123+
profiles.append({**profile, "confidence": cc_conf})
124+
else:
125+
print(" chess.com: no match found", file=sys.stderr)
126+
127+
if not pgn_strings:
128+
print(" No games found — generating skeleton dossier.", file=sys.stderr)
129+
130+
dossier = build_dossier(name, pgn_strings, profiles=profiles,
131+
depth=depth, top=top)
132+
133+
if fmt == "json":
134+
content = render_json(dossier)
135+
ext = "json"
136+
else:
137+
content = render_markdown(dossier)
138+
ext = "md"
139+
140+
path = out / f"{_slug(name)}.{ext}"
141+
path.write_text(content, encoding="utf-8")
142+
written.append(path)
143+
combined_parts.append(content)
144+
print(f" Saved → {path}", file=sys.stderr)
145+
146+
# --- Combined output ---
147+
if combined_parts and fmt == "markdown":
148+
sep = "\n\n---\n\n"
149+
combined = out / "combined.md"
150+
combined.write_text(sep.join(combined_parts), encoding="utf-8")
151+
written.append(combined)
152+
print(f"\nCombined → {combined}", file=sys.stderr)
153+
154+
print(f"\nDone. {len(players)} dossier(s) written to {out}/", file=sys.stderr)
155+
return written
156+
157+
158+
def main() -> None:
159+
parser = argparse.ArgumentParser(
160+
description="Generate dossiers for all opponents in a tournament."
161+
)
162+
parser.add_argument("tournament", help="Tournament ID or full URL")
163+
parser.add_argument("--site", choices=["kingregistration", "chessaction"],
164+
default="kingregistration")
165+
parser.add_argument("--output-dir", default="dossiers",
166+
help="Directory to write dossier files (default: dossiers/)")
167+
parser.add_argument("--max-games", type=int, default=50,
168+
help="Max games to fetch per player from Lichess (default: 50)")
169+
parser.add_argument("--chesscom-months", type=int, default=3,
170+
help="Months of chess.com history to fetch (default: 3)")
171+
parser.add_argument("--depth", type=int, default=6,
172+
help="Opening depth in half-moves (default: 6)")
173+
parser.add_argument("--top", type=int, default=8,
174+
help="Top N opening lines per colour (default: 8)")
175+
parser.add_argument("--format", dest="fmt", choices=["markdown", "json"],
176+
default="markdown")
177+
args = parser.parse_args()
178+
179+
run_pipeline(
180+
args.tournament,
181+
site=args.site,
182+
output_dir=args.output_dir,
183+
max_games=args.max_games,
184+
chesscom_months=args.chesscom_months,
185+
depth=args.depth,
186+
top=args.top,
187+
fmt=args.fmt,
188+
)
189+
190+
191+
if __name__ == "__main__":
192+
main()

0 commit comments

Comments
 (0)