Skip to content

Commit 3722007

Browse files
jlhe97claude
andauthored
Step 5: dossier report generator
dossier/report.py: pure build_dossier() ties together analyse_stats() and analyse_openings() into a structured dict. render_markdown() produces a headed Markdown report with online profile links, W/D/L overview table, and per-colour opening tables (incl. vs 1.e4 and 1.d4 breakdowns). render_json() serialises the same dict. CLI accepts --pgn, --megabase, --lichess, --chesscom as game/profile sources. 24 new tests covering dossier structure, markdown sections, JSON validity, profile inclusion/omission, and vs_e4/vs_d4 rendering. All 5 pipeline steps now complete. README updated with Step 5 usage, sample output, Python API, and roadmap fully checked off. https://claude.ai/code/session_01VQfqug9MDEyRFydkmES4n2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8bdb003 commit 3722007

4 files changed

Lines changed: 467 additions & 1 deletion

File tree

README.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,71 @@ openings = analyse_openings(pgn_strings, "Smith, John", depth=6, top=10)
242242
stats = analyse_stats(pgn_strings, "Smith, John")
243243
```
244244

245+
## Step 5 — Dossier report
246+
247+
Ties the full pipeline together into a single Markdown or JSON report per opponent.
248+
249+
```bash
250+
# From a PGN file
251+
python -m dossier.report "Smith, John" --pgn games.pgn
252+
253+
# From the MegaDatabase index
254+
python -m dossier.report "Smith, John" --megabase megabase.db
255+
256+
# Both sources combined, with online profiles
257+
python -m dossier.report "Smith, John" \
258+
--megabase megabase.db \
259+
--lichess smithj \
260+
--chesscom JohnSmith99 \
261+
--output markdown > smith_john.md
262+
263+
# JSON output (for further processing)
264+
python -m dossier.report "Smith, John" --megabase megabase.db --output json
265+
```
266+
267+
### Sample output
268+
269+
```markdown
270+
# Dossier: Smith, John
271+
*Generated 2026-04-21 · 50 games analysed*
272+
273+
## Online Profiles
274+
- **Lichess**: [jsmith](https://lichess.org/@/jsmith) — Rapid: 1750, Blitz: 1700
275+
276+
## Overview
277+
| | White | Black | Overall |
278+
|---|---|---|---|
279+
| Games | 27 | 23 | 50 |
280+
| Win % | 55.6% | 43.5% | 50.0% |
281+
282+
## As White
283+
| Opening | Games | W | D | L | Win% |
284+
|---|---|---|---|---|---|
285+
| `1. e4 e5 2. Nf3 Nc6 3. Bb5` | 18 | 10 | 5 | 3 | 55.6% |
286+
287+
## As Black
288+
### vs 1. e4
289+
| Opening | Games | W | D | L | Win% |
290+
|---|---|---|---|---|---|
291+
| `1. e4 c5 2. Nf3 d6 3. d4 cxd4` | 10 | 5 | 3 | 2 | 50.0% |
292+
```
293+
294+
### Python API
295+
296+
```python
297+
from dossier.report import build_dossier, render_markdown
298+
299+
pgn_strings = [game["pgn"] for game in megabase_games]
300+
profiles = [lichess_profile, chesscom_profile]
301+
302+
dossier = build_dossier("Smith, John", pgn_strings, profiles=profiles)
303+
print(render_markdown(dossier))
304+
```
305+
245306
## Roadmap
246307

247308
- [x] Step 1 — Scrape tournament entry lists (kingregistration, chessaction)
248309
- [x] Step 2 — Index ChessBase MegaDatabase for fast player lookups
249310
- [x] Step 3 — Look up each player on Lichess and chess.com
250311
- [x] Step 4 — Analyse openings and tendencies
251-
- [ ] Step 5 — Generate per-opponent dossier report
312+
- [x] Step 5 — Generate per-opponent dossier report

dossier/__init__.py

Whitespace-only changes.

dossier/report.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
"""
2+
Dossier report generator — ties together all pipeline steps.
3+
4+
Core function build_dossier() is pure: accepts PGN strings + optional
5+
profile dicts and returns a rendered report. The CLI wires up data sources.
6+
7+
Usage:
8+
python -m dossier.report "Smith, John" --pgn games.pgn
9+
python -m dossier.report "Smith, John" --megabase megabase.db
10+
python -m dossier.report "Smith, John" --megabase megabase.db --lichess smithj
11+
python -m dossier.report "Smith, John" --output json
12+
"""
13+
14+
import sys
15+
import json
16+
import argparse
17+
import re
18+
from datetime import date
19+
20+
from analysis.openings import analyse_openings
21+
from analysis.stats import analyse_stats
22+
23+
24+
# ---------------------------------------------------------------------------
25+
# Core (pure, no I/O)
26+
# ---------------------------------------------------------------------------
27+
28+
def build_dossier(player: str, pgn_strings: list[str],
29+
profiles: list[dict] | None = None,
30+
depth: int = 6, top: int = 8) -> dict:
31+
"""
32+
Run full analysis and return a structured dossier dict.
33+
34+
Args:
35+
player: Player name as it appears in PGN headers.
36+
pgn_strings: List of PGN game strings to analyse.
37+
profiles: Optional list of online profile dicts
38+
(from lookup.lichess / lookup.chesscom).
39+
depth: Opening depth in half-moves.
40+
top: Max opening lines to include per colour.
41+
42+
Returns a dict suitable for render_markdown() or render_json().
43+
"""
44+
stats = analyse_stats(pgn_strings, player)
45+
openings = analyse_openings(pgn_strings, player, depth=depth, top=top)
46+
47+
return {
48+
"player": player,
49+
"profiles": profiles or [],
50+
"stats": stats,
51+
"openings": openings,
52+
"generated": date.today().isoformat(),
53+
}
54+
55+
56+
# ---------------------------------------------------------------------------
57+
# Renderers
58+
# ---------------------------------------------------------------------------
59+
60+
def render_markdown(dossier: dict) -> str:
61+
player = dossier["player"]
62+
stats = dossier["stats"]
63+
openings = dossier["openings"]
64+
profiles = dossier["profiles"]
65+
generated = dossier["generated"]
66+
67+
lines = [
68+
f"# Dossier: {player}",
69+
f"*Generated {generated} · {stats['total']} games analysed*",
70+
"",
71+
]
72+
73+
# --- Online profiles ---
74+
if profiles:
75+
lines += ["## Online Profiles", ""]
76+
for p in profiles:
77+
site = "Lichess" if "lichess" in p.get("url", "") else "chess.com"
78+
ratings = ", ".join(
79+
f"{k.capitalize()}: {v}" for k, v in p.get("ratings", {}).items()
80+
)
81+
title = f"{p['title']} " if p.get("title") else ""
82+
lines.append(f"- **{site}**: [{title}{p['display_name']}]({p['url']})"
83+
+ (f" — {ratings}" if ratings else ""))
84+
lines.append("")
85+
86+
# --- Overview ---
87+
ov = stats["overall"]
88+
lines += [
89+
"## Overview",
90+
"",
91+
f"| | White | Black | Overall |",
92+
f"|---|---|---|---|",
93+
f"| Games | {stats['as_white']['count']} | {stats['as_black']['count']} | {stats['total']} |",
94+
f"| Wins | {stats['as_white']['wins']} | {stats['as_black']['wins']} | {ov['wins']} |",
95+
f"| Draws | {stats['as_white']['draws']} | {stats['as_black']['draws']} | {ov['draws']} |",
96+
f"| Losses | {stats['as_white']['losses']} | {stats['as_black']['losses']} | {ov['losses']} |",
97+
f"| Win % | {stats['as_white']['win_pct']}% | {stats['as_black']['win_pct']}% | {ov['win_pct']}% |",
98+
"",
99+
f"**Average game length:** {stats['avg_length']} half-moves",
100+
"",
101+
]
102+
103+
# --- As White ---
104+
lines += ["## As White", ""]
105+
if openings["as_white"]:
106+
lines += _opening_table(openings["as_white"])
107+
else:
108+
lines.append("*No games found as White.*")
109+
lines.append("")
110+
111+
# --- As Black ---
112+
lines += ["## As Black", ""]
113+
114+
if stats["vs_e4"]:
115+
lines.append("### vs 1. e4")
116+
lines.append("")
117+
lines += _opening_table(stats["vs_e4"])
118+
lines.append("")
119+
120+
if stats["vs_d4"]:
121+
lines.append("### vs 1. d4")
122+
lines.append("")
123+
lines += _opening_table(stats["vs_d4"])
124+
lines.append("")
125+
126+
if openings["as_black"]:
127+
lines.append("### All openings as Black")
128+
lines.append("")
129+
lines += _opening_table(openings["as_black"])
130+
else:
131+
lines.append("*No games found as Black.*")
132+
lines.append("")
133+
134+
return "\n".join(lines)
135+
136+
137+
def render_json(dossier: dict) -> str:
138+
return json.dumps(dossier, indent=2, ensure_ascii=False)
139+
140+
141+
def _opening_table(rows: list[dict]) -> list[str]:
142+
out = [
143+
"| Opening | Games | W | D | L | Win% |",
144+
"|---|---|---|---|---|---|",
145+
]
146+
for r in rows:
147+
out.append(
148+
f"| `{r['line']}` | {r['count']} | {r['wins']} "
149+
f"| {r['draws']} | {r['losses']} | {r['win_pct']}% |"
150+
)
151+
return out
152+
153+
154+
# ---------------------------------------------------------------------------
155+
# CLI — wires up data sources
156+
# ---------------------------------------------------------------------------
157+
158+
def _load_pgns_from_file(path: str) -> list[str]:
159+
with open(path, encoding="utf-8", errors="replace") as fh:
160+
content = fh.read()
161+
return [g for g in re.split(r"\n(?=\[)", content.strip()) if g.strip()]
162+
163+
164+
def _load_pgns_from_megabase(player: str, db_path: str) -> list[str]:
165+
from megabase.query import get_player_games
166+
games = get_player_games(player, db_path=db_path)
167+
print(f"Megabase: {len(games)} game(s) found.", file=sys.stderr)
168+
return [g["pgn"] for g in games]
169+
170+
171+
def _load_profile_lichess(username: str) -> dict | None:
172+
try:
173+
from lookup.lichess import get_profile
174+
return get_profile(username)
175+
except Exception as e:
176+
print(f"Lichess lookup failed: {e}", file=sys.stderr)
177+
return None
178+
179+
180+
def _load_profile_chesscom(username: str) -> dict | None:
181+
try:
182+
from lookup.chesscom import get_profile
183+
return get_profile(username)
184+
except Exception as e:
185+
print(f"chess.com lookup failed: {e}", file=sys.stderr)
186+
return None
187+
188+
189+
def main() -> None:
190+
parser = argparse.ArgumentParser(
191+
description="Generate a chess dossier for an opponent."
192+
)
193+
parser.add_argument("player", help="Player name (as in PGN headers)")
194+
parser.add_argument("--pgn", metavar="FILE", help="PGN file of games")
195+
parser.add_argument("--megabase", metavar="DB", help="SQLite megabase index")
196+
parser.add_argument("--lichess", metavar="USER", help="Lichess username for profile")
197+
parser.add_argument("--chesscom", metavar="USER", help="chess.com username for profile")
198+
parser.add_argument("--depth", type=int, default=6,
199+
help="Opening depth in half-moves (default: 6)")
200+
parser.add_argument("--top", type=int, default=8,
201+
help="Top N opening lines per colour (default: 8)")
202+
parser.add_argument("--output", choices=["markdown", "json"], default="markdown")
203+
args = parser.parse_args()
204+
205+
pgn_strings: list[str] = []
206+
if args.pgn:
207+
pgn_strings += _load_pgns_from_file(args.pgn)
208+
if args.megabase:
209+
pgn_strings += _load_pgns_from_megabase(args.player, args.megabase)
210+
211+
if not pgn_strings:
212+
print("Error: provide at least one game source (--pgn or --megabase).",
213+
file=sys.stderr)
214+
sys.exit(1)
215+
216+
print(f"Total: {len(pgn_strings)} game(s) to analyse.", file=sys.stderr)
217+
218+
profiles: list[dict] = []
219+
if args.lichess:
220+
p = _load_profile_lichess(args.lichess)
221+
if p:
222+
profiles.append(p)
223+
if args.chesscom:
224+
p = _load_profile_chesscom(args.chesscom)
225+
if p:
226+
profiles.append(p)
227+
228+
dossier = build_dossier(args.player, pgn_strings, profiles=profiles,
229+
depth=args.depth, top=args.top)
230+
231+
if args.output == "json":
232+
print(render_json(dossier))
233+
else:
234+
print(render_markdown(dossier))
235+
236+
237+
if __name__ == "__main__":
238+
main()

0 commit comments

Comments
 (0)