chess-tutor / src /chess_tutor /web /narrative_sections.py
github-actions[bot]
deploy prod from 06dbd16a01ddcfe02b2d936c681e3e4eaa9b141f
8e756fd
Raw
History Blame Contribute Delete
6.6 kB
"""Split and filter coaching narrative text for the web UI."""
from __future__ import annotations
from chess_tutor.cohort.opening_families import bucket_display_name
from chess_tutor.web.sections import (
NarrativeBlockId,
narrative_block_skipped,
section_enabled,
WebSectionId,
)
_OPENING_BUCKET_TITLES = frozenset(
bucket_display_name(bucket)
for bucket in ("white", "black_vs_e4", "black_vs_d4", "black_vs_c4", "other")
) | {"Other"}
_WEB_NARRATIVE_SKIP_TITLES = frozenset(
{"Opening patterns", "Top opening positions", "Puzzle practice"}
)
_TOP_LEVEL_NARRATIVE_HEADERS = frozenset(
{
"Story for this player",
"Your focus this week",
"This week's checklist",
"Examples from your games",
"Opening patterns",
"Top opening positions",
"Practice plan",
"Puzzle practice",
}
)
def _is_top_level_narrative_header(line: str, stripped: str) -> bool:
if not stripped or line.startswith((" ", "\t")) or stripped.startswith("-"):
return False
return stripped.rstrip(":") in _TOP_LEVEL_NARRATIVE_HEADERS
def _strip_web_duplicate_sections(narrative: str) -> str:
"""Remove opening/puzzle blocks when dedicated UI cards show them."""
skip_titles: set[str] = set()
if section_enabled(WebSectionId.OPENINGS) and narrative_block_skipped(
NarrativeBlockId.OPENING_DUPLICATE
):
skip_titles.add("Opening patterns")
skip_titles.add("Top opening positions")
if section_enabled(WebSectionId.PUZZLES) and narrative_block_skipped(
NarrativeBlockId.PUZZLE_DUPLICATE
):
skip_titles.add("Puzzle practice")
if not skip_titles:
return narrative.strip()
lines = narrative.splitlines()
result: list[str] = []
skipping = False
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
if stripped in skip_titles or stripped.rstrip(":") in skip_titles:
skipping = True
i += 1
continue
if skipping:
if _is_top_level_narrative_header(line, stripped):
skipping = False
else:
i += 1
continue
result.append(line)
i += 1
return "\n".join(result).strip()
def _looks_like_opening_section(section: dict[str, str]) -> bool:
title = section["title"]
body = section["body"]
if title in _WEB_NARRATIVE_SKIP_TITLES or title in _OPENING_BUCKET_TITLES:
return True
if title.startswith("#") and " — " in title:
return True
return "You play:" in body and "Recommended:" in body
def _looks_like_puzzle_section(section: dict[str, str]) -> bool:
title = section["title"]
body = section["body"]
if title in _WEB_NARRATIVE_SKIP_TITLES:
return True
if "Rated ladder" in title or "Rated ladder" in body:
return True
if "lichess.org/training/" in body:
return True
if "Themes:" in body and " Elo (" in body:
return True
return False
def _looks_like_coaching_report_section(section: dict[str, str]) -> bool:
title = section["title"].strip()
if title in {"Coaching report", "Story for this player"}:
return True
return section["body"].strip().startswith("Story for this player")
def _looks_like_cohort_tendency_section(section: dict[str, str]) -> bool:
title = section["title"].strip().rstrip(":")
return title.startswith("Compared to others at your rating")
_NARRATIVE_BLOCK_MATCHERS: dict[NarrativeBlockId, object] = {
NarrativeBlockId.COACHING_REPORT: _looks_like_coaching_report_section,
NarrativeBlockId.COHORT_TENDENCY: _looks_like_cohort_tendency_section,
NarrativeBlockId.OPENING_DUPLICATE: _looks_like_opening_section,
NarrativeBlockId.PUZZLE_DUPLICATE: _looks_like_puzzle_section,
}
_CLIMBER_SECTION_PREFIX = "Players who climbed from your bracket toward"
def _web_friendly_narrative_title(title: str) -> str:
stripped = title.strip().rstrip(":")
if stripped.startswith(_CLIMBER_SECTION_PREFIX):
return "Playstyle changes (from similar climbers)"
if stripped == "Practical emphasis (by priority)":
return "Recommended playstyle changes"
return title
def _should_skip_narrative_section(
section: dict[str, str],
*,
filters: tuple | None = None,
) -> bool:
for block_id, matcher in _NARRATIVE_BLOCK_MATCHERS.items():
if narrative_block_skipped(block_id, filters=filters) and matcher(section):
return True
return False
def _split_narrative_sections(narrative: str) -> list[dict[str, str]]:
"""Split coaching narrative into titled sections."""
text = narrative.strip()
if not text:
return []
sections: list[dict[str, str]] = []
current_title = "Coaching report"
current_lines: list[str] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped and not current_lines:
continue
if (
stripped
and not stripped.startswith("-")
and not line.startswith(" ")
and not line.startswith("\t")
and stripped.endswith(":")
and len(stripped) < 80
):
if current_lines:
sections.append(
{
"title": current_title,
"body": "\n".join(current_lines).strip(),
}
)
current_title = stripped.rstrip(":")
current_lines = []
continue
current_lines.append(line.rstrip())
if current_lines:
sections.append(
{
"title": current_title,
"body": "\n".join(current_lines).strip(),
}
)
return sections
def build_web_narrative_sections(
narrative: str,
*,
narrative_filters: tuple | None = None,
) -> list[dict[str, str]]:
"""Narrative blocks for the web UI, respecting section registry filters."""
if not section_enabled(WebSectionId.NARRATIVE):
return []
cleaned = _strip_web_duplicate_sections(narrative)
sections: list[dict[str, str]] = []
for section in _split_narrative_sections(cleaned):
if _should_skip_narrative_section(section, filters=narrative_filters):
continue
sections.append(
{
"title": _web_friendly_narrative_title(section["title"]),
"body": section["body"],
}
)
return sections