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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/chapterwise/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Summarize books chapter by chapter with an LLM."""

__version__ = "0.3.0"
__version__ = "0.4.0"
20 changes: 10 additions & 10 deletions src/chapterwise/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
InlineKeyboardMarkup,
LinkPreviewOptions,
Update,
WebAppInfo,
)
from telegram.constants import ParseMode
from telegram.error import BadRequest
Expand All @@ -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


Expand Down
6 changes: 4 additions & 2 deletions src/chapterwise/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
39 changes: 26 additions & 13 deletions src/chapterwise/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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()
Expand Down Expand Up @@ -223,7 +227,7 @@ def record_delivery(

# --- sending ---------------------------------------------------------------------------------------------

Button = tuple[str, str] # (label, "cb:<data>" or "url:<https://...>")
Button = tuple[str, str] # (label, "cb:<data>", "url:<https://...>" or "app:<https://...>" for a Mini App)


class Sender(Protocol):
Expand All @@ -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"<b>{escape(item.book_title, quote=False)}</b>\n"
f"{escape(item.section_title, quote=False)} · {item.position + 1}/{item.total}"
f"<b>{escape(item.book_title, quote=False)}</b>\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


Expand Down
34 changes: 34 additions & 0 deletions src/chapterwise/links.py
Original file line number Diff line number Diff line change
@@ -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)}"
6 changes: 4 additions & 2 deletions src/chapterwise/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand Down
83 changes: 64 additions & 19 deletions src/chapterwise/prompts.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,73 @@
"""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]:
user = f"""Book: {book}
Section: {section}

Summarize this section.
{_SUMMARY_SHAPE}
{_CARD_SHAPE}

<text>
{text}
Expand All @@ -35,33 +80,33 @@ 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>
{text}
</text>"""
return SYSTEM, user
return NOTES_SYSTEM, user


def condense_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. 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>
{notes}
</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>
{notes}
Expand Down
Loading
Loading