diff --git a/README.md b/README.md index 09b8a1c..f1d2573 100644 --- a/README.md +++ b/README.md @@ -91,11 +91,35 @@ chapterwise migrate chapterwise serve & chapterwise worker ``` +## Reading experience + +Summaries are written for easy reading, including with ADHD: +- **Lead with the point:** short sentences, one idea per sentence, plain words, key words in bold. +- **Structured cards:** the model returns JSON, which is validated, retried once, and falls back to + Markdown if still unusable. Each card has: + - a one-line **TL;DR** and **why it matters** + - 3–6 **key ideas**, each with an everyday **example** + - **words to know** + - a **takeaway** and one tiny **"try this"** + - 2–3 **quick-check** questions +- **Interactive article:** + - **Focus mode** shows one idea at a time (arrow keys work). + - **"Got it"** checks track your progress and are remembered on that device. + - Examples and quiz answers open when you tap them. + - Text size is adjustable. + - Everything is readable without JavaScript. +- **Old summaries are still shown:** ones made before this format display as before, with a button + to redo them in the new format (per section or per book). Long chapters reuse their cached notes, + so only the final call is paid for again. + ## Daily reading on Telegram Pick a book with `/books` (or **Read daily on Telegram** on its web page). Every morning at `CHAPTERWISE_DELIVERY_TIME` (07:30 Europe/Rome by default) the bot sends the next section's summary, -with buttons to get the **Next** one right away, **Pause**, or open it on the web. +as a short card (TL;DR, key ideas, takeaway), with buttons for **Next**, **Pause** and +**📖 Read the article**. That button opens the full interactive article inside Telegram as a Mini App, +using a signed link (valid 120 days, read-only, one section) so no login is needed. It also follows +Telegram's light or dark theme. - **Summaries are prepared ahead:** the next 2 sections are queued for the worker, so the morning message is ready on time. diff --git a/pyproject.toml b/pyproject.toml index 74ab445..7cabbf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "chapterwise" -version = "0.3.0" +version = "0.4.0" description = "Summarize your books chapter by chapter with an LLM and read them in micro-doses." readme = "README.md" license = "MIT" diff --git a/src/chapterwise/__init__.py b/src/chapterwise/__init__.py index d87904a..065e089 100644 --- a/src/chapterwise/__init__.py +++ b/src/chapterwise/__init__.py @@ -1,3 +1,3 @@ """Summarize books chapter by chapter with an LLM.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/src/chapterwise/bot.py b/src/chapterwise/bot.py index 1a2a409..685c96d 100644 --- a/src/chapterwise/bot.py +++ b/src/chapterwise/bot.py @@ -18,6 +18,7 @@ InlineKeyboardMarkup, LinkPreviewOptions, Update, + WebAppInfo, ) from telegram.constants import ParseMode from telegram.error import BadRequest @@ -40,17 +41,16 @@ ] +def button(label: str, target: str) -> InlineKeyboardButton: + if target.startswith("app:"): # opens the page inside Telegram (Mini App) + return InlineKeyboardButton(label, web_app=WebAppInfo(url=target[4:])) + if target.startswith("url:"): + return InlineKeyboardButton(label, url=target[4:]) + return InlineKeyboardButton(label, callback_data=target[3:]) + + def keyboard(buttons: list[list[delivery.Button]]) -> InlineKeyboardMarkup | None: - rows = [ - [ - InlineKeyboardButton(label, url=target[4:]) - if target.startswith("url:") - else InlineKeyboardButton(label, callback_data=target[3:]) - for label, target in row - ] - for row in buttons - if row - ] + rows = [[button(label, target) for label, target in row] for row in buttons if row] return InlineKeyboardMarkup(rows) if rows else None diff --git a/src/chapterwise/cli.py b/src/chapterwise/cli.py index f615661..df2ce2e 100644 --- a/src/chapterwise/cli.py +++ b/src/chapterwise/cli.py @@ -15,6 +15,7 @@ from chapterwise.models import Document from chapterwise.parsers import ParseError, parse from chapterwise.summarize import Summarizer, Usage +from chapterwise.summary_format import card_to_markdown, try_parse_card app = typer.Typer( add_completion=False, @@ -139,7 +140,8 @@ def summarize( typer.echo(f"Error from the model on section {i}: {exc}", err=True) raise typer.Exit(1) from exc total += usage - blocks.append(f"## {section.title}\n\n{summary}") + card = try_parse_card(summary) + blocks.append(f"## {section.title}\n\n{card_to_markdown(card) if card else summary}") markdown = "\n\n".join(blocks) + "\n" if out: @@ -231,5 +233,5 @@ def __init__(self, model: str): def model(self) -> str: return self._model - def complete(self, system: str, user: str): + def complete(self, system: str, user: str, json_mode: bool = False): raise RuntimeError("dry run must not call the model") diff --git a/src/chapterwise/delivery.py b/src/chapterwise/delivery.py index 70730ba..80e21bf 100644 --- a/src/chapterwise/delivery.py +++ b/src/chapterwise/delivery.py @@ -21,8 +21,10 @@ from chapterwise.cache import PostgresCache from chapterwise.config import Settings from chapterwise.db import OPEN_SUBSCRIPTION_STATUSES, Delivery, DocumentRow, SectionRow, Subscription -from chapterwise.summarize import cache_key -from chapterwise.telegram_format import build_message +from chapterwise.links import read_url +from chapterwise.summarize import find_summary +from chapterwise.summary_format import try_parse_card +from chapterwise.telegram_format import build_card_message, build_message log = logging.getLogger(__name__) @@ -63,6 +65,7 @@ class Item: total: int summary: str kind: str # scheduled | manual + summary_format: str = "markdown" # card | markdown @property def is_last(self) -> bool: @@ -181,8 +184,8 @@ def due_items( if doc is None or section is None: # past the end, e.g. after a re-parse shortened the book sub.status, sub.wants_next = "finished", False continue - entry = cache.get(cache_key("section", section.text_sha256, doc.title, section.title, settings.model)) - if entry is None: + stored = find_summary(cache, section.text_sha256, doc.title, section.title, settings.model) + if stored is None: prefetch(session, sub) continue items.append( @@ -193,8 +196,9 @@ def due_items( section_title=section.title, position=sub.position, total=total, - summary=entry["text"], + summary=stored.text, kind="scheduled" if scheduled else "manual", + summary_format=stored.format, ) ) session.commit() @@ -223,7 +227,7 @@ def record_delivery( # --- sending --------------------------------------------------------------------------------------------- -Button = tuple[str, str] # (label, "cb:" or "url:") +Button = tuple[str, str] # (label, "cb:", "url:" or "app:" for a Mini App) class Sender(Protocol): @@ -233,19 +237,28 @@ async def notify(self, text: str) -> None: ... def message_for(item: Item, settings: Settings) -> tuple[str, list[list[Button]]]: + card = try_parse_card(item.summary) if item.summary_format == "card" else None + meta = f"{item.position + 1}/{item.total}" + (f" · ⏱ {card.minutes} min" if card else "") header = ( - f"{escape(item.book_title, quote=False)}\n" - f"{escape(item.section_title, quote=False)} · {item.position + 1}/{item.total}" + f"{escape(item.book_title, quote=False)}\n{escape(item.section_title, quote=False)} · {meta}" ) - html, _truncated = build_message(header, item.summary) + html, _truncated = build_card_message(header, card) if card else build_message(header, item.summary) + + buttons: list[list[Button]] = [] + # Telegram only opens public https pages. With the secret key, the article opens inside Telegram + # through a signed link, with no login; without it, the normal (login) page opens in the browser. + if settings.public_url.startswith("https://"): + if settings.secret_key: + url = read_url(settings.public_url, settings.secret_key, item.document_id, item.position) + buttons.append([("📖 Read the article", f"app:{url}")]) + else: + url = f"{settings.public_url}/b/{item.document_id}/s/{item.position}" + buttons.append([("📖 Open on the web", f"url:{url}")]) row: list[Button] = [] if not item.is_last: row.append(("Next ▶", f"cb:next:{item.subscription_id}")) row.append(("Pause", f"cb:pause:{item.subscription_id}")) - buttons = [row] - if settings.public_url.startswith("https://"): # Telegram rejects non-public button URLs - url = f"{settings.public_url}/b/{item.document_id}/s/{item.position}" - buttons.append([("Open on the web", f"url:{url}")]) + buttons.append(row) return html, buttons diff --git a/src/chapterwise/links.py b/src/chapterwise/links.py new file mode 100644 index 0000000..d09f27c --- /dev/null +++ b/src/chapterwise/links.py @@ -0,0 +1,34 @@ +"""Signed read links: open one section's article without logging in (used by the Telegram bot). + +A link names a book and a section and is signed with CHAPTERWISE_SECRET_KEY, so it can't be altered to +reach anything else. It only grants reading; links expire after READ_LINK_MAX_AGE. +""" + +from __future__ import annotations + +from itsdangerous import BadSignature, URLSafeTimedSerializer + +READ_LINK_MAX_AGE = 120 * 24 * 3600 # seconds +_SALT = "chapterwise.read-link" + + +def make_read_token(secret_key: str, document_id: int, position: int) -> str: + return URLSafeTimedSerializer(secret_key, salt=_SALT).dumps({"d": document_id, "p": position}) + + +def read_token_target(secret_key: str, token: str) -> tuple[int, int] | None: + """(document_id, position) for a valid, unexpired token; None otherwise.""" + try: + data = URLSafeTimedSerializer(secret_key, salt=_SALT).loads(token, max_age=READ_LINK_MAX_AGE) + except BadSignature: # includes SignatureExpired + return None + if not isinstance(data, dict): + return None + document_id, position = data.get("d"), data.get("p") + if not isinstance(document_id, int) or not isinstance(position, int): + return None + return document_id, position + + +def read_url(public_url: str, secret_key: str, document_id: int, position: int) -> str: + return f"{public_url}/r/{make_read_token(secret_key, document_id, position)}" diff --git a/src/chapterwise/llm.py b/src/chapterwise/llm.py index 89a472d..ad1a00b 100644 --- a/src/chapterwise/llm.py +++ b/src/chapterwise/llm.py @@ -21,7 +21,7 @@ class LLM(Protocol): @property def model(self) -> str: ... - def complete(self, system: str, user: str) -> Completion: ... + def complete(self, system: str, user: str, json_mode: bool = False) -> Completion: ... class OpenAICompatibleLLM: @@ -36,10 +36,12 @@ def __init__(self, api_key: str, base_url: str, model: str, timeout: float = 300 def model(self) -> str: return self._model - def complete(self, system: str, user: str) -> Completion: + def complete(self, system: str, user: str, json_mode: bool = False) -> Completion: + extra = {"response_format": {"type": "json_object"}} if json_mode else {} response = self._client.chat.completions.create( model=self._model, messages=[{"role": "system", "content": system}, {"role": "user", "content": user}], + **extra, ) if not response.choices: raise LLMError("The model returned no choices") diff --git a/src/chapterwise/prompts.py b/src/chapterwise/prompts.py index b4d59ca..58a680f 100644 --- a/src/chapterwise/prompts.py +++ b/src/chapterwise/prompts.py @@ -1,20 +1,65 @@ -"""Prompts. Bump PROMPT_VERSION whenever a prompt changes, so cached summaries are regenerated.""" +"""Prompts. -from __future__ import annotations +Bump PROMPT_VERSION when the final-summary prompts change: those summaries are regenerated on request, +while summaries from LEGACY_PROMPT_VERSIONS are still shown until then. NOTES_VERSION covers the +intermediate notes of long sections; keeping it separate means a new summary style reuses old notes. +""" -PROMPT_VERSION = "1" +from __future__ import annotations -SYSTEM = ( - "You summarize sections of books for a reader who wants to understand and remember them. " - "Always write in the same language as the source text. " - "Use only what the text says; never add outside facts. Answer in Markdown without a top-level title." +PROMPT_VERSION = "2" +LEGACY_PROMPT_VERSIONS = ("1",) +NOTES_VERSION = "1" + +# Final summaries: structured "cards", written for a reader with ADHD. +SYSTEM = """\ +You turn sections of books into summaries for a busy reader with ADHD who wants to understand \ +and remember what they read. + +Write every value in the same language as the source text. JSON keys stay in English. + +How to write: +- Lead with the point. No warm-up, no filler, no "In this chapter, the author…". +- Short sentences, about 15 words at most. One idea per sentence. +- Plain, everyday words. A term a newcomer wouldn't know goes in "terms" with a plain meaning. +- Concrete beats abstract: give each idea a real example from the text or a short everyday analogy. +- Mark the 1-2 words that carry each idea in **bold**. Use bold sparingly, never whole sentences. +- Keep every field short. Nothing longer than 2 sentences unless asked. +- Use only what the text says. Never add outside facts; an analogy must be clearly an analogy. + +Answer with a single JSON object and nothing else.""" + +_CARD_SHAPE = """\ +Return a JSON object with exactly these keys: +{ + "tldr": "the whole section in one sentence, max 25 words", + "why_it_matters": "1-2 sentences: why this is worth knowing", + "key_ideas": [ + { + "emoji": "one emoji that fits the idea", + "title": "a 3-6 word headline", + "point": "the idea in 1-2 short sentences", + "example": "a concrete example from the text, or a short everyday analogy (1-2 sentences)" + } + ], + "terms": [{"term": "a word from the text", "meaning": "its meaning in plain words, max 15 words"}], + "takeaway": "the single most important point, one sentence", + "try_this": "one tiny, concrete thing to do or notice today, one sentence; empty string if none fits", + "quick_check": [{"question": "a short question to test recall", "answer": "the answer, one sentence"}] +} +Rules: 3-6 key_ideas, in the order the text presents them. 0-5 terms, only words a newcomer would \ +not know. 2-3 quick_check items.""" + +# Intermediate notes for long sections (plain text, merged later). +NOTES_SYSTEM = ( + "You take notes on parts of books. Write in the same language as the source text. " + "Use only what the text says; never add outside facts." ) -_SUMMARY_SHAPE = """\ -- Start with a 1-2 sentence overview. -- Then the key ideas as bullet points (at most 8), each with a short explanation. -- Keep the names, terms and numbers the text uses. -- End with one line starting with "**Takeaway:**" giving the single most important point.""" +INVALID_JSON_NUDGE = ( + "\n\nYour previous answer was not a valid JSON object in the required format. " + "Reply with the JSON object only." +) def section_summary(book: str, section: str, text: str) -> tuple[str, str]: @@ -22,7 +67,7 @@ def section_summary(book: str, section: str, text: str) -> tuple[str, str]: Section: {section} Summarize this section. -{_SUMMARY_SHAPE} +{_CARD_SHAPE} {text} @@ -35,12 +80,12 @@ def part_notes(book: str, section: str, part: int, parts: int, text: str) -> tup Section: {section} (part {part} of {parts}) Write concise bullet-point notes of everything important in this part: ideas, arguments, events, -names, terms and numbers. They will be merged with the notes of the other parts later. +examples, names, terms and numbers. They will be merged with the notes of the other parts later. {text} """ - return SYSTEM, user + return NOTES_SYSTEM, user def condense_notes(book: str, section: str, notes: str) -> tuple[str, str]: @@ -48,20 +93,20 @@ def condense_notes(book: str, section: str, notes: str) -> tuple[str, str]: Section: {section} These are notes from consecutive parts of the section. Condense them into shorter bullet-point notes, -keeping every important idea, name, term and number. +keeping every important idea, example, name, term and number. {notes} """ - return SYSTEM, user + return NOTES_SYSTEM, user def merge_notes(book: str, section: str, notes: str) -> tuple[str, str]: user = f"""Book: {book} Section: {section} -These are notes from consecutive parts of the section, in order. Write the summary of the whole section. -{_SUMMARY_SHAPE} +These are notes from consecutive parts of the section, in order. Summarize the whole section. +{_CARD_SHAPE} {notes} diff --git a/src/chapterwise/static/article.css b/src/chapterwise/static/article.css new file mode 100644 index 0000000..ebe428e --- /dev/null +++ b/src/chapterwise/static/article.css @@ -0,0 +1,112 @@ +/* The summary article: calm, chunked, one idea per card. Works without JavaScript; reader.js adds + focus mode, "got it" checks, text size and Telegram theme colors. */ + +:root { + --reading-scale: 1; + --tint: color-mix(in srgb, var(--accent) 9%, var(--surface)); + --tint-strong: color-mix(in srgb, var(--accent) 16%, var(--surface)); + --radius: 14px; +} + +/* The app's generic
styling (a divider above each one) doesn't belong inside the article. */ +.article :where(details) { margin-top: 0; border-top: 0; padding-top: 0; } + +.article { max-width: 680px; margin: 0 auto; font-size: calc(1.0625rem * var(--reading-scale)); } +.article p { margin: 0; } +.article strong { font-weight: 650; background: linear-gradient(transparent 62%, color-mix(in srgb, var(--accent) 22%, transparent) 0); } + +.crumbs { font-size: 0.875rem; margin-bottom: 10px; } +.article h1 { font-size: 1.75em; line-height: 1.2; margin: 0 0 10px; letter-spacing: -0.015em; } +.meta { display: flex; flex-wrap: wrap; gap: 6px 14px; color: var(--muted); font-size: 0.875rem; } +.bookbar { height: 4px; border-radius: 99px; background: var(--line); margin: 14px 0 22px; overflow: hidden; } +.bookbar > span { display: block; height: 100%; background: var(--accent); border-radius: inherit; } + +/* Controls appear only when the script runs. */ +.controls { display: none; gap: 8px; align-items: center; flex-wrap: wrap; margin: 0 0 18px; } +.js .controls { display: flex; } +.controls button { padding: 6px 12px; border-radius: 99px; } +.controls .spacer { flex: 1; } +.got-count { font-size: 0.875rem; color: var(--muted); font-variant-numeric: tabular-nums; } + +.block { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; margin: 0 0 14px; } +.label { display: block; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; } + +.tldr { background: var(--tint-strong); border-color: transparent; font-size: 1.15em; line-height: 1.45; } +.tldr .label { color: var(--accent); } +.why { display: flex; gap: 10px; align-items: flex-start; background: transparent; border-style: dashed; } +.why .icon { font-size: 1.2em; line-height: 1.3; } + +.section-title { font-size: 0.8rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 26px 0 10px; } + +.ideas { list-style: none; padding: 0; margin: 0; counter-reset: idea; } +.idea { position: relative; padding-left: 18px; transition: opacity 0.2s, border-color 0.2s; } +.idea-head { display: flex; gap: 10px; align-items: baseline; margin-bottom: 6px; } +.idea .num { flex: none; width: 1.9em; height: 1.9em; border-radius: 50%; display: inline-grid; place-items: center; + background: var(--tint-strong); color: var(--accent); font-weight: 700; font-size: 0.85em; transform: translateY(-1px); } +.idea h3 { margin: 0; font-size: 1.05em; line-height: 1.3; } +.idea .emoji { margin-right: 2px; } +.idea .point { line-height: 1.6; } +.idea details { margin-top: 10px; } +.idea summary { cursor: pointer; color: var(--accent); font-size: 0.9em; font-weight: 600; list-style: none; } +.idea summary::-webkit-details-marker { display: none; } +.idea summary::before { content: "▸ "; } +.idea details[open] summary::before { content: "▾ "; } +.idea .example { margin-top: 6px; padding: 10px 12px; border-radius: 10px; background: var(--tint); line-height: 1.55; } +.idea.done { border-color: color-mix(in srgb, var(--ok) 55%, var(--line)); } +.idea.done .num { background: var(--ok); color: var(--surface); } +.got-it { display: none; margin-top: 12px; } +.js .got-it { display: inline-block; } +.got-it[aria-pressed="true"] { background: var(--ok); border-color: var(--ok); color: var(--surface); } + +.terms { display: flex; flex-wrap: wrap; gap: 8px; } +.term { border: 1px solid var(--line); border-radius: 99px; background: var(--surface); padding: 4px 12px; } +.term[open] { border-radius: 12px; flex-basis: 100%; } +.term summary { cursor: pointer; font-weight: 600; list-style: none; } +.term summary::-webkit-details-marker { display: none; } +.term p { padding: 4px 0 6px; color: var(--muted); } + +.takeaway { background: var(--accent); border-color: transparent; color: var(--accent-text); font-size: 1.08em; line-height: 1.45; } +.takeaway .label { color: inherit; opacity: 0.8; } +.takeaway strong { background: none; text-decoration: underline; text-underline-offset: 3px; } +.try { display: flex; gap: 10px; align-items: flex-start; } + +.qa { border: 1px solid var(--line); border-radius: 12px; padding: 10px 14px; margin-bottom: 8px; background: var(--surface); } +.qa summary { cursor: pointer; font-weight: 600; } +.qa .answer { margin-top: 8px; color: var(--muted); } +.qa .hint { font-weight: 400; color: var(--muted); font-size: 0.85em; margin-left: 6px; } +.qa[open] .hint { display: none; } + +.legacy { line-height: 1.65; } +.legacy :is(h1, h2, h3) { font-size: 1.1em; } + +.original { margin-top: 28px; border-top: 1px solid var(--line); padding-top: 12px; } +.original pre { white-space: pre-wrap; font: inherit; color: var(--muted); line-height: 1.6; } + +.chapters { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 28px; } +.chapters a { display: block; border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; background: var(--surface); } +.chapters a:hover { text-decoration: none; border-color: var(--accent); } +.chapters .dir { display: block; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; } +.chapters .next { text-align: right; grid-column: 2; } + +/* Focus mode: one idea at a time. */ +.focus-nav { display: none; } +.article.focus .focus-hide { display: none; } +.article.focus .idea { display: none; } +.article.focus .idea.current { display: block; font-size: 1.12em; padding: 22px; box-shadow: 0 8px 30px color-mix(in srgb, var(--text) 8%, transparent); } +.article.focus .idea.current details { display: block; } +.article.focus .focus-nav { display: flex; gap: 10px; align-items: center; justify-content: space-between; margin-top: 14px; } +.focus-step { color: var(--muted); font-variant-numeric: tabular-nums; } + +/* Inside Telegram: no site chrome, Telegram's own colors (set by reader.js); lines follow its hint color. */ +:root.in-telegram { --line: color-mix(in srgb, var(--muted) 28%, transparent); } +.in-telegram header { display: none; } +.in-telegram main { padding-top: 14px; } + +@media (max-width: 640px) { + .article { font-size: calc(1.03rem * var(--reading-scale)); } + .block { padding: 14px 15px; } + .chapters { grid-template-columns: 1fr; } + .chapters .next { grid-column: 1; } +} + +@media (prefers-reduced-motion: reduce) { .idea { transition: none; } } diff --git a/src/chapterwise/static/reader.js b/src/chapterwise/static/reader.js new file mode 100644 index 0000000..6f348b6 --- /dev/null +++ b/src/chapterwise/static/reader.js @@ -0,0 +1,121 @@ +// Progressive enhancement for the summary article. The page is complete without this file. +(() => { + "use strict"; + const root = document.documentElement; + root.classList.add("js"); + + const store = { + get(key) { + try { return JSON.parse(localStorage.getItem(key)); } catch { return null; } + }, + set(key, value) { + try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* private mode: no memory */ } + }, + }; + + // --- Inside Telegram (opened from the bot): use Telegram's colors, take the full height. --- + const hash = new URLSearchParams(location.hash.slice(1)); + if (hash.has("tgWebAppVersion") || hash.has("tgWebAppData") || hash.has("tgWebAppThemeParams")) { + root.classList.add("in-telegram"); + try { + const theme = JSON.parse(hash.get("tgWebAppThemeParams") || "{}"); + const vars = { + "--bg": theme.bg_color, + "--surface": theme.secondary_bg_color || theme.bg_color, + "--text": theme.text_color, + "--muted": theme.hint_color, + "--accent": theme.button_color || theme.link_color, + "--accent-text": theme.button_text_color, + }; + for (const [name, value] of Object.entries(vars)) { + if (/^#[0-9a-f]{3,8}$/i.test(value || "")) root.style.setProperty(name, value); + } + } catch { /* keep our own colors */ } + const post = (type) => { + const data = JSON.stringify({ eventType: type, eventData: {} }); + if (window.TelegramWebviewProxy) window.TelegramWebviewProxy.postEvent(type, "{}"); + else if (window.external && "notify" in window.external) window.external.notify(data); + else if (window.parent !== window) window.parent.postMessage(data, "https://web.telegram.org"); + }; + post("web_app_ready"); + post("web_app_expand"); + } + + const article = document.querySelector("[data-article]"); + if (!article) return; + + // --- Text size --- + let scale = Number(store.get("cw:scale")) || 0; + const applyScale = () => root.style.setProperty("--reading-scale", String(1 + scale * 0.08)); + applyScale(); + article.querySelectorAll("[data-scale]").forEach((button) => { + button.addEventListener("click", () => { + scale = Math.max(-2, Math.min(4, scale + Number(button.dataset.scale))); + store.set("cw:scale", scale); + applyScale(); + }); + }); + + // --- "Got it" checks with progress, remembered per article --- + const ideas = [...article.querySelectorAll(".idea")]; + const key = `cw:got:${article.dataset.article}`; + const done = new Set(store.get(key) || []); + const counter = article.querySelector(".got-count"); + const render = () => { + ideas.forEach((idea, n) => { + const on = done.has(n); + idea.classList.toggle("done", on); + const button = idea.querySelector(".got-it"); + button.setAttribute("aria-pressed", String(on)); + button.textContent = on ? "✓ Got it" : "Got it"; + }); + if (counter) counter.textContent = `${done.size}/${ideas.length} got it`; + }; + ideas.forEach((idea, n) => { + idea.querySelector(".got-it").addEventListener("click", () => { + done.has(n) ? done.delete(n) : done.add(n); + store.set(key, [...done]); + render(); + if (article.classList.contains("focus") && done.has(n)) step(1); + }); + }); + render(); + + // --- Focus mode: one idea at a time --- + let current = 0; + const stepLabel = article.querySelector(".focus-step"); + const focusButton = article.querySelector("[data-focus]"); + const show = () => { + ideas.forEach((idea, n) => idea.classList.toggle("current", n === current)); + if (stepLabel) stepLabel.textContent = `${current + 1} / ${ideas.length}`; + article.querySelector("[data-focus-prev]").disabled = current === 0; + article.querySelector("[data-focus-next]").textContent = current === ideas.length - 1 ? "Finish ✓" : "Next →"; + }; + const setFocus = (on) => { + article.classList.toggle("focus", on); + focusButton.setAttribute("aria-pressed", String(on)); + focusButton.textContent = on ? "Show everything" : "🎯 One idea at a time"; + if (on) { show(); ideas[current].scrollIntoView({ block: "center", behavior: "smooth" }); } + }; + const step = (delta) => { + const next = current + delta; + if (next >= ideas.length) { + setFocus(false); + (article.querySelector(".takeaway") || article).scrollIntoView({ block: "center", behavior: "smooth" }); + return; + } + current = Math.max(0, next); + show(); + }; + if (focusButton && ideas.length) { + focusButton.addEventListener("click", () => setFocus(!article.classList.contains("focus"))); + article.querySelector("[data-focus-prev]").addEventListener("click", () => step(-1)); + article.querySelector("[data-focus-next]").addEventListener("click", () => step(1)); + document.addEventListener("keydown", (event) => { + if (!article.classList.contains("focus") || event.target.closest("input, textarea")) return; + if (event.key === "ArrowRight") step(1); + else if (event.key === "ArrowLeft") step(-1); + else if (event.key === "Escape") setFocus(false); + }); + } +})(); diff --git a/src/chapterwise/summarize.py b/src/chapterwise/summarize.py index 4ed3e21..54cdc8e 100644 --- a/src/chapterwise/summarize.py +++ b/src/chapterwise/summarize.py @@ -1,8 +1,14 @@ -"""Section summaries: one call for sections that fit, map-reduce for longer ones. Everything is cached.""" +"""Section summaries: one call for sections that fit, map-reduce for longer ones. Everything is cached. + +A finished summary is stored as {"text": ..., "format": "card" | "markdown"}: "card" is the validated +JSON of summary_format.Card; "markdown" is the fallback when the model doesn't return usable JSON, and +the format of summaries made with older prompt versions. +""" from __future__ import annotations import hashlib +import logging from dataclasses import dataclass from chapterwise import prompts @@ -10,9 +16,12 @@ from chapterwise.chunking import estimate_tokens, split_text from chapterwise.llm import LLM from chapterwise.models import Document, Section +from chapterwise.summary_format import InvalidSummary, parse_card + +log = logging.getLogger(__name__) # Rough allowance for the instructions wrapped around the text in each call. -PROMPT_OVERHEAD_TOKENS = 250 +PROMPT_OVERHEAD_TOKENS = 600 @dataclass @@ -37,25 +46,56 @@ class Estimate: input_tokens: int +@dataclass(frozen=True) +class StoredSummary: + text: str + format: str # "card" | "markdown" + current: bool # made with the current prompt version + + def text_sha256(text: str) -> str: return hashlib.sha256(text.encode()).hexdigest() def cache_key( - kind: str, text_hash: str, book_title: str, section_title: str, model: str, **extra: str + kind: str, + text_hash: str, + book_title: str, + section_title: str, + model: str, + prompt_version: str | None = None, + **extra: str, ) -> dict[str, str]: """The cache key of one LLM result. kind="section" is a finished section summary.""" + if prompt_version is None: + prompt_version = prompts.PROMPT_VERSION if kind == "section" else prompts.NOTES_VERSION return { "kind": kind, "text_sha256": text_hash, "book": book_title, "section": section_title, "model": model, - "prompt_version": prompts.PROMPT_VERSION, + "prompt_version": prompt_version, **extra, } +def summary_keys(text_hash: str, book_title: str, section_title: str, model: str) -> list[dict[str, str]]: + """Keys a finished summary may be stored under: current prompt version first, then older ones.""" + versions = (prompts.PROMPT_VERSION, *prompts.LEGACY_PROMPT_VERSIONS) + return [cache_key("section", text_hash, book_title, section_title, model, v) for v in versions] + + +def find_summary( + cache: Cache, text_hash: str, book_title: str, section_title: str, model: str +) -> StoredSummary | None: + """The newest stored summary of a section, if any.""" + for n, key in enumerate(summary_keys(text_hash, book_title, section_title, model)): + if (entry := cache.get(key)) is not None: + return StoredSummary(entry["text"], entry.get("format", "markdown"), current=n == 0) + return None + + class Summarizer: def __init__(self, llm: LLM, cache: Cache, max_chunk_tokens: int = 24_000): self.llm = llm @@ -82,6 +122,7 @@ def is_cached(self, doc: Document, section: Section) -> bool: return self.cache.get(self._key("section", section.text, doc, section)) is not None def summarize_section(self, doc: Document, section: Section) -> tuple[str, Usage]: + """The section's summary text (card JSON, or Markdown as a fallback) and what it cost.""" usage = Usage() key = self._key("section", section.text, doc, section) if (hit := self.cache.get(key)) is not None: @@ -93,7 +134,7 @@ def summarize_section(self, doc: Document, section: Section) -> tuple[str, Usage raise ValueError(f"Section {section.index} ('{section.title}') has no text") if len(chunks) == 1: - text = self._call(prompts.section_summary(doc.title, section.title, chunks[0]), usage) + prompt = prompts.section_summary(doc.title, section.title, chunks[0]) else: notes = [ self._cached_call( @@ -108,11 +149,30 @@ def summarize_section(self, doc: Document, section: Section) -> tuple[str, Usage for n, chunk in enumerate(chunks, start=1) ] notes = self._condense(doc, section, notes, usage) - text = self._call(prompts.merge_notes(doc.title, section.title, "\n\n".join(notes)), usage) + prompt = prompts.merge_notes(doc.title, section.title, "\n\n".join(notes)) - self.cache.set(key, {"text": text, "model": self.llm.model, "prompt_version": prompts.PROMPT_VERSION}) + text, fmt = self._card(prompt, usage) + self.cache.set( + key, + {"text": text, "format": fmt, "model": self.llm.model, "prompt_version": prompts.PROMPT_VERSION}, + ) return text, usage + def _card(self, prompt: tuple[str, str], usage: Usage) -> tuple[str, str]: + """Ask for the JSON card; retry once if it's unusable, then keep the answer as Markdown.""" + system, user = prompt + answer = self._call((system, user), usage, json_mode=True) + for attempt in (1, 2): + try: + parse_card(answer) + return answer, "card" + except InvalidSummary as exc: + if attempt == 2: + log.warning("Model answer isn't a valid card (%s); keeping it as Markdown", exc) + return answer, "markdown" + answer = self._call((system, user + prompts.INVALID_JSON_NUDGE), usage, json_mode=True) + raise AssertionError("unreachable") + def _condense(self, doc: Document, section: Section, notes: list[str], usage: Usage) -> list[str]: """Shrink the notes until they fit in one merge call (only needed for very long sections).""" while len(notes) > 1 and estimate_tokens("\n\n".join(notes)) > self.max_chunk_tokens: @@ -153,8 +213,8 @@ def _cached_call( self.cache.set(key, {"text": result}) return result - def _call(self, prompt: tuple[str, str], usage: Usage) -> str: - completion = self.llm.complete(*prompt) + def _call(self, prompt: tuple[str, str], usage: Usage, json_mode: bool = False) -> str: + completion = self.llm.complete(*prompt, json_mode=json_mode) usage.llm_calls += 1 usage.tokens_in += completion.tokens_in usage.tokens_out += completion.tokens_out diff --git a/src/chapterwise/summary_format.py b/src/chapterwise/summary_format.py new file mode 100644 index 0000000..3ebdd24 --- /dev/null +++ b/src/chapterwise/summary_format.py @@ -0,0 +1,166 @@ +"""Structured summaries ("cards"): validating what the model returns, and simple text renderings. + +The model is asked for JSON (see prompts.py). Anything it returns is untrusted: fields are type-checked, +trimmed and capped here, and every renderer escapes the text it outputs. +""" + +from __future__ import annotations + +import json +import math +import re +from dataclasses import dataclass + +MAX_FIELD = 600 +MAX_IDEAS = 8 +MAX_TERMS = 8 +MAX_CHECKS = 4 +READING_WPM = 200 + +_FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.IGNORECASE) +_BOLD = re.compile(r"\*\*(.+?)\*\*") + + +class InvalidSummary(ValueError): + """The model's answer isn't a usable card.""" + + +@dataclass(frozen=True) +class Idea: + title: str + point: str + example: str + emoji: str + + +@dataclass(frozen=True) +class Term: + term: str + meaning: str + + +@dataclass(frozen=True) +class Check: + question: str + answer: str + + +@dataclass(frozen=True) +class Card: + tldr: str + why: str + ideas: tuple[Idea, ...] + terms: tuple[Term, ...] + takeaway: str + action: str + checks: tuple[Check, ...] + + def all_text(self) -> str: + parts = [self.tldr, self.why, self.takeaway, self.action] + parts += [f"{i.title} {i.point} {i.example}" for i in self.ideas] + parts += [f"{t.term} {t.meaning}" for t in self.terms] + parts += [f"{c.question} {c.answer}" for c in self.checks] + return " ".join(p for p in parts if p) + + @property + def minutes(self) -> int: + """Reading time of the summary itself.""" + return max(1, math.ceil(len(strip_bold(self.all_text()).split()) / READING_WPM)) + + +def strip_bold(text: str) -> str: + return _BOLD.sub(r"\1", text) + + +def _text(value, limit: int = MAX_FIELD) -> str: + if value is None: + return "" + if not isinstance(value, str | int | float): + return "" + text = " ".join(str(value).split()) + return text if len(text) <= limit else text[: limit - 1].rstrip() + "…" + + +def _emoji(value) -> str: + text = _text(value, 16).replace(" ", "") + # A short symbol only: no words, no markup. + if not text or len(text) > 8 or any(ch.isalnum() or ch in "<>&\"'" for ch in text): + return "" + return text + + +def _items(value, limit: int) -> list[dict]: + return [item for item in value if isinstance(item, dict)][:limit] if isinstance(value, list) else [] + + +def parse_card(raw: str) -> Card: + """Validate the model's JSON answer. Raises InvalidSummary if it can't be used.""" + try: + data = json.loads(_FENCE.sub("", raw or "")) + except (json.JSONDecodeError, TypeError) as exc: + raise InvalidSummary(f"not JSON: {exc}") from exc + if not isinstance(data, dict): + raise InvalidSummary("not a JSON object") + + ideas = tuple( + Idea( + title=_text(item.get("title"), 120), + point=_text(item.get("point")), + example=_text(item.get("example")), + emoji=_emoji(item.get("emoji")), + ) + for item in _items(data.get("key_ideas"), MAX_IDEAS) + ) + ideas = tuple(i for i in ideas if i.title and i.point) + tldr = _text(data.get("tldr"), 300) + if not tldr or not ideas: + raise InvalidSummary("missing tldr or key ideas") + + terms = tuple( + Term(_text(item.get("term"), 80), _text(item.get("meaning"), 200)) + for item in _items(data.get("terms"), MAX_TERMS) + ) + checks = tuple( + Check(_text(item.get("question"), 300), _text(item.get("answer"), 400)) + for item in _items(data.get("quick_check"), MAX_CHECKS) + ) + return Card( + tldr=tldr, + why=_text(data.get("why_it_matters")), + ideas=ideas, + terms=tuple(t for t in terms if t.term and t.meaning), + takeaway=_text(data.get("takeaway")), + action=_text(data.get("try_this")), + checks=tuple(c for c in checks if c.question and c.answer), + ) + + +def try_parse_card(raw: str) -> Card | None: + try: + return parse_card(raw) + except InvalidSummary: + return None + + +def card_to_markdown(card: Card) -> str: + """Plain Markdown version (CLI output, exports).""" + lines = [f"**TL;DR:** {card.tldr}"] + if card.why: + lines += ["", f"**Why it matters:** {card.why}"] + lines += ["", "### Key ideas"] + for n, idea in enumerate(card.ideas, start=1): + emoji = f"{idea.emoji} " if idea.emoji else "" + lines.append(f"{n}. {emoji}**{strip_bold(idea.title)}**: {idea.point}") + if idea.example: + lines.append(f" - *Example:* {idea.example}") + if card.terms: + lines += ["", "### Terms"] + lines += [f"- **{t.term}**: {t.meaning}" for t in card.terms] + if card.takeaway: + lines += ["", f"**Takeaway:** {card.takeaway}"] + if card.action: + lines += ["", f"**Try this:** {card.action}"] + if card.checks: + lines += ["", "### Quick check"] + lines += [f"- {c.question}\n - *{c.answer}*" for c in card.checks] + return "\n".join(lines) diff --git a/src/chapterwise/telegram_format.py b/src/chapterwise/telegram_format.py index 7b82433..961a7e1 100644 --- a/src/chapterwise/telegram_format.py +++ b/src/chapterwise/telegram_format.py @@ -1,4 +1,4 @@ -"""Turn Markdown summaries into the small HTML subset Telegram accepts. +"""Turn summaries (cards or Markdown) into the small HTML subset Telegram accepts. Telegram supports only
  
, no lists or headings, and caps a message at 4096 characters. Lists become bullet lines, headings become bold lines, and long summaries @@ -7,11 +7,14 @@ from __future__ import annotations +import re from html import escape from bs4 import BeautifulSoup, NavigableString, Tag from markdown_it import MarkdownIt +from chapterwise.summary_format import Card, strip_bold + MESSAGE_BUDGET = 3800 # leaves room under Telegram's 4096-character limit _markdown = MarkdownIt("commonmark", {"html": False}).enable(["table", "strikethrough"]) @@ -105,3 +108,38 @@ def build_message(header: str, markdown: str, budget: int = MESSAGE_BUDGET) -> t def to_plain_text(html: str) -> str: """Fallback if Telegram ever rejects the HTML: same content without markup.""" return BeautifulSoup(html, "html.parser").get_text() + + +_BOLD = re.compile(r"\*\*(.+?)\*\*") + + +def _tg(text: str) -> str: + """Escape model text for Telegram HTML and keep its **bold** markers as .""" + return _BOLD.sub(r"\1", escape(text or "", quote=False)) + + +def card_blocks(card: Card) -> list[str]: + """A short, scannable Telegram version of a summary card; the full article is one tap away.""" + blocks = [f"💡 TL;DR\n{_tg(card.tldr)}"] + ideas = [ + f"{idea.emoji or '•'} {_tg(strip_bold(idea.title))}\n{_tg(idea.point)}" for idea in card.ideas + ] + blocks.append("Key ideas\n\n" + "\n\n".join(ideas)) + if card.takeaway: + blocks.append(f"🎯 Takeaway: {_tg(card.takeaway)}") + if card.action: + blocks.append(f"✅ Try this: {_tg(card.action)}") + return blocks + + +def build_card_message(header: str, card: Card, budget: int = MESSAGE_BUDGET) -> tuple[str, bool]: + """Header (already HTML) + card blocks, cut at a block boundary to fit. Returns (html, truncated).""" + parts = [header] + used = len(header) + for block in card_blocks(card): + if used + len(block) + 2 > budget: + parts.append("… (open the article for the rest)") + return "\n\n".join(parts), True + parts.append(block) + used += len(block) + 2 + return "\n\n".join(parts), False diff --git a/src/chapterwise/templates/_article.html b/src/chapterwise/templates/_article.html new file mode 100644 index 0000000..1f7c26c --- /dev/null +++ b/src/chapterwise/templates/_article.html @@ -0,0 +1,131 @@ +{# The summary article, shared by the logged-in section page and the signed read link (Telegram). + Context: doc, section, total, card, legacy, state, prev_href, next_href, prev, next, back_href, + article_key, summarize_form, upgrade_form. #} +
+

+ {% if back_href %}← {{ doc.title }}{% else %}{{ doc.title }}{% endif %} +

+

{{ section.title }}

+
+ Section {{ section.position + 1 }} of {{ total }} + {% if card %}⏱ {{ card.minutes }} min read{{ card.ideas|length }} key ideas{% endif %} + {{ section.words|num }} words in the original +
+
+ +
+ + {% if card %} +
+ + + + + +
+ +
+ TL;DR +

{{ card.tldr|emph }}

+
+ {% if card.why %} +
+ +

Why it matters{{ card.why|emph }}

+
+ {% endif %} + +

Key ideas

+
    + {% for idea in card.ideas %} +
  1. +
    + +

    {% if idea.emoji %} {% endif %}{{ idea.title|emph }}

    +
    +

    {{ idea.point|emph }}

    + {% if idea.example %} +
    + Example +

    {{ idea.example|emph }}

    +
    + {% endif %} + +
  2. + {% endfor %} +
+
+ + + +
+ + {% if card.terms %} +

Words to know

+
+ {% for term in card.terms %} +
{{ term.term }}

{{ term.meaning|emph }}

+ {% endfor %} +
+ {% endif %} + + {% if card.takeaway %} +
+ 🎯 Takeaway +

{{ card.takeaway|emph }}

+
+ {% endif %} + {% if card.action %} +
+ +

Try this today{{ card.action|emph }}

+
+ {% endif %} + + {% if card.checks %} +

Quick check

+
+ {% for check in card.checks %} +
+ {{ check.question|emph }}tap to reveal +

{{ check.answer|emph }}

+
+ {% endfor %} +
+ {% endif %} + + {% elif legacy %} +
{{ legacy|markdown|safe }}
+ {% if upgrade_form %} +
+ + +
+ {% endif %} + + {% elif state == "running" %} +

Summarizing now… this page refreshes by itself.

+ {% elif state == "queued" %} +

Queued for summarizing… this page refreshes by itself.

+ {% elif summarize_form %} +
+ + +
+ {% else %} +

The summary is still being prepared. Check back in a minute.

+ {% endif %} + +
+ Original text +
{{ section.text }}
+
+ + {% if prev or next %} + + {% endif %} +
diff --git a/src/chapterwise/templates/base.html b/src/chapterwise/templates/base.html index d2305f7..341ee7e 100644 --- a/src/chapterwise/templates/base.html +++ b/src/chapterwise/templates/base.html @@ -5,6 +5,7 @@ {% if refresh %}{% endif %} {% block title %}chapterwise{% endblock %} +{% block head %}{% endblock %}