unread 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unread-0.1.0/.claude/skills/unread-dev/SKILL.md +418 -0
- unread-0.1.0/.claude/skills/unread-usage/SKILL.md +594 -0
- unread-0.1.0/.env.example +9 -0
- unread-0.1.0/.github/workflows/ci.yml +63 -0
- unread-0.1.0/.github/workflows/release.yml +49 -0
- unread-0.1.0/.gitignore +230 -0
- unread-0.1.0/.releaserc.json +52 -0
- unread-0.1.0/CLAUDE.md +428 -0
- unread-0.1.0/LICENSE +21 -0
- unread-0.1.0/PKG-INFO +1197 -0
- unread-0.1.0/README.md +1144 -0
- unread-0.1.0/ROADMAP.md +240 -0
- unread-0.1.0/config.toml.example +182 -0
- unread-0.1.0/docs/superpowers/plans/2026-04-24-unified-chat-run-pipeline.md +2243 -0
- unread-0.1.0/docs/superpowers/plans/2026-04-26-ask-wizard-and-followup.md +1406 -0
- unread-0.1.0/docs/superpowers/specs/2026-04-24-unified-chat-run-pipeline-design.md +453 -0
- unread-0.1.0/docs/superpowers/specs/2026-04-26-ask-wizard-and-followup-design.md +127 -0
- unread-0.1.0/presets/README.md +96 -0
- unread-0.1.0/presets/en/_base.md +83 -0
- unread-0.1.0/presets/en/_reduce.md +22 -0
- unread-0.1.0/presets/en/action_items.md +52 -0
- unread-0.1.0/presets/en/broad.md +53 -0
- unread-0.1.0/presets/en/decisions.md +50 -0
- unread-0.1.0/presets/en/digest.md +46 -0
- unread-0.1.0/presets/en/highlights.md +59 -0
- unread-0.1.0/presets/en/links.md +60 -0
- unread-0.1.0/presets/en/multichat.md +77 -0
- unread-0.1.0/presets/en/questions.md +58 -0
- unread-0.1.0/presets/en/quotes.md +56 -0
- unread-0.1.0/presets/en/reactions.md +66 -0
- unread-0.1.0/presets/en/single_msg.md +62 -0
- unread-0.1.0/presets/en/summary.md +94 -0
- unread-0.1.0/presets/en/video.md +87 -0
- unread-0.1.0/presets/en/website.md +93 -0
- unread-0.1.0/presets/ru/_base.md +85 -0
- unread-0.1.0/presets/ru/_reduce.md +22 -0
- unread-0.1.0/presets/ru/action_items.md +53 -0
- unread-0.1.0/presets/ru/broad.md +52 -0
- unread-0.1.0/presets/ru/decisions.md +49 -0
- unread-0.1.0/presets/ru/digest.md +45 -0
- unread-0.1.0/presets/ru/highlights.md +57 -0
- unread-0.1.0/presets/ru/links.md +58 -0
- unread-0.1.0/presets/ru/multichat.md +80 -0
- unread-0.1.0/presets/ru/questions.md +57 -0
- unread-0.1.0/presets/ru/quotes.md +55 -0
- unread-0.1.0/presets/ru/reactions.md +68 -0
- unread-0.1.0/presets/ru/single_msg.md +62 -0
- unread-0.1.0/presets/ru/summary.md +93 -0
- unread-0.1.0/presets/ru/video.md +91 -0
- unread-0.1.0/presets/ru/website.md +89 -0
- unread-0.1.0/pyproject.toml +75 -0
- unread-0.1.0/reports/.gitkeep +0 -0
- unread-0.1.0/storage/.gitkeep +0 -0
- unread-0.1.0/storage/media/.gitkeep +0 -0
- unread-0.1.0/tests/__init__.py +0 -0
- unread-0.1.0/tests/conftest.py +29 -0
- unread-0.1.0/tests/test_analysis_cache_keys.py +79 -0
- unread-0.1.0/tests/test_app_settings.py +93 -0
- unread-0.1.0/tests/test_ask_enrich.py +296 -0
- unread-0.1.0/tests/test_ask_followup_prompt.py +69 -0
- unread-0.1.0/tests/test_ask_global_flag.py +50 -0
- unread-0.1.0/tests/test_ask_mark_read.py +362 -0
- unread-0.1.0/tests/test_ask_mutual_exclusion.py +131 -0
- unread-0.1.0/tests/test_ask_ref_resolution.py +64 -0
- unread-0.1.0/tests/test_ask_wizard_dispatch.py +193 -0
- unread-0.1.0/tests/test_chat_picker_layout.py +348 -0
- unread-0.1.0/tests/test_chunker.py +62 -0
- unread-0.1.0/tests/test_cli_argv.py +134 -0
- unread-0.1.0/tests/test_cli_helpers.py +60 -0
- unread-0.1.0/tests/test_content_language_semantics.py +92 -0
- unread-0.1.0/tests/test_download_media.py +101 -0
- unread-0.1.0/tests/test_enrich_link.py +55 -0
- unread-0.1.0/tests/test_enrich_optional_deps.py +165 -0
- unread-0.1.0/tests/test_enrich_opts.py +112 -0
- unread-0.1.0/tests/test_enrich_preset.py +44 -0
- unread-0.1.0/tests/test_estimate_cost_locale.py +42 -0
- unread-0.1.0/tests/test_filters.py +63 -0
- unread-0.1.0/tests/test_folders.py +166 -0
- unread-0.1.0/tests/test_formatter.py +192 -0
- unread-0.1.0/tests/test_formatter_enrich.py +83 -0
- unread-0.1.0/tests/test_formatter_topics.py +143 -0
- unread-0.1.0/tests/test_hasher.py +45 -0
- unread-0.1.0/tests/test_i18n.py +45 -0
- unread-0.1.0/tests/test_interactive.py +308 -0
- unread-0.1.0/tests/test_interactive_ask_mode.py +238 -0
- unread-0.1.0/tests/test_interactive_cost_fmt.py +64 -0
- unread-0.1.0/tests/test_links.py +121 -0
- unread-0.1.0/tests/test_media_no_audio.py +43 -0
- unread-0.1.0/tests/test_openai_client.py +207 -0
- unread-0.1.0/tests/test_options_payload_locale.py +63 -0
- unread-0.1.0/tests/test_prepared_run.py +414 -0
- unread-0.1.0/tests/test_pricing.py +96 -0
- unread-0.1.0/tests/test_project_quality.py +37 -0
- unread-0.1.0/tests/test_prompts.py +185 -0
- unread-0.1.0/tests/test_prompts_locale.py +73 -0
- unread-0.1.0/tests/test_reactions.py +156 -0
- unread-0.1.0/tests/test_regressions_deep_review.py +712 -0
- unread-0.1.0/tests/test_repo_cache.py +387 -0
- unread-0.1.0/tests/test_repo_datetime.py +52 -0
- unread-0.1.0/tests/test_repo_enrichments.py +129 -0
- unread-0.1.0/tests/test_repo_msgid.py +72 -0
- unread-0.1.0/tests/test_report_header.py +126 -0
- unread-0.1.0/tests/test_report_paths.py +177 -0
- unread-0.1.0/tests/test_resolver.py +29 -0
- unread-0.1.0/tests/test_resolver_fallthrough.py +47 -0
- unread-0.1.0/tests/test_run_settings.py +101 -0
- unread-0.1.0/tests/test_settings_supported_languages.py +85 -0
- unread-0.1.0/tests/test_single_msg.py +105 -0
- unread-0.1.0/tests/test_system_prompt_composition.py +107 -0
- unread-0.1.0/tests/test_tokens.py +59 -0
- unread-0.1.0/tests/test_topic_markers_filter.py +134 -0
- unread-0.1.0/tests/test_truncation_banner.py +64 -0
- unread-0.1.0/tests/test_website_command.py +207 -0
- unread-0.1.0/tests/test_website_content.py +155 -0
- unread-0.1.0/tests/test_website_repo.py +130 -0
- unread-0.1.0/tests/test_website_urls.py +140 -0
- unread-0.1.0/tests/test_with_comments.py +143 -0
- unread-0.1.0/tests/test_youtube_command.py +498 -0
- unread-0.1.0/tests/test_youtube_repo.py +202 -0
- unread-0.1.0/tests/test_youtube_urls.py +97 -0
- unread-0.1.0/unread/__init__.py +3 -0
- unread-0.1.0/unread/analyzer/__init__.py +1 -0
- unread-0.1.0/unread/analyzer/chunker.py +119 -0
- unread-0.1.0/unread/analyzer/commands.py +2137 -0
- unread-0.1.0/unread/analyzer/filters.py +115 -0
- unread-0.1.0/unread/analyzer/formatter.py +413 -0
- unread-0.1.0/unread/analyzer/hasher.py +45 -0
- unread-0.1.0/unread/analyzer/openai_client.py +192 -0
- unread-0.1.0/unread/analyzer/pipeline.py +877 -0
- unread-0.1.0/unread/analyzer/prompts.py +535 -0
- unread-0.1.0/unread/ask/__init__.py +14 -0
- unread-0.1.0/unread/ask/commands.py +985 -0
- unread-0.1.0/unread/ask/embeddings.py +205 -0
- unread-0.1.0/unread/ask/rerank.py +149 -0
- unread-0.1.0/unread/ask/retrieval.py +205 -0
- unread-0.1.0/unread/cli.py +2007 -0
- unread-0.1.0/unread/config.py +330 -0
- unread-0.1.0/unread/core/__init__.py +5 -0
- unread-0.1.0/unread/core/paths.py +131 -0
- unread-0.1.0/unread/core/pipeline.py +795 -0
- unread-0.1.0/unread/core/run.py +85 -0
- unread-0.1.0/unread/db/__init__.py +1 -0
- unread-0.1.0/unread/db/repo.py +1825 -0
- unread-0.1.0/unread/db/schema.sql +268 -0
- unread-0.1.0/unread/enrich/__init__.py +13 -0
- unread-0.1.0/unread/enrich/audio.py +208 -0
- unread-0.1.0/unread/enrich/base.py +117 -0
- unread-0.1.0/unread/enrich/document.py +255 -0
- unread-0.1.0/unread/enrich/image.py +212 -0
- unread-0.1.0/unread/enrich/link.py +280 -0
- unread-0.1.0/unread/enrich/pipeline.py +222 -0
- unread-0.1.0/unread/enrich/video.py +41 -0
- unread-0.1.0/unread/export/__init__.py +1 -0
- unread-0.1.0/unread/export/commands.py +935 -0
- unread-0.1.0/unread/export/markdown.py +119 -0
- unread-0.1.0/unread/i18n.py +1624 -0
- unread-0.1.0/unread/interactive.py +2319 -0
- unread-0.1.0/unread/media/__init__.py +1 -0
- unread-0.1.0/unread/media/commands.py +317 -0
- unread-0.1.0/unread/media/download.py +203 -0
- unread-0.1.0/unread/models.py +108 -0
- unread-0.1.0/unread/runner.py +720 -0
- unread-0.1.0/unread/settings/__init__.py +6 -0
- unread-0.1.0/unread/settings/commands.py +633 -0
- unread-0.1.0/unread/tg/__init__.py +1 -0
- unread-0.1.0/unread/tg/client.py +81 -0
- unread-0.1.0/unread/tg/commands.py +1287 -0
- unread-0.1.0/unread/tg/dialogs.py +238 -0
- unread-0.1.0/unread/tg/folders.py +145 -0
- unread-0.1.0/unread/tg/links.py +118 -0
- unread-0.1.0/unread/tg/resolver.py +252 -0
- unread-0.1.0/unread/tg/sync.py +375 -0
- unread-0.1.0/unread/tg/topics.py +151 -0
- unread-0.1.0/unread/util/__init__.py +1 -0
- unread-0.1.0/unread/util/flood.py +99 -0
- unread-0.1.0/unread/util/logging.py +54 -0
- unread-0.1.0/unread/util/pricing.py +46 -0
- unread-0.1.0/unread/util/tokens.py +31 -0
- unread-0.1.0/unread/website/__init__.py +1 -0
- unread-0.1.0/unread/website/commands.py +332 -0
- unread-0.1.0/unread/website/content.py +438 -0
- unread-0.1.0/unread/website/metadata.py +25 -0
- unread-0.1.0/unread/website/paths.py +51 -0
- unread-0.1.0/unread/website/urls.py +135 -0
- unread-0.1.0/unread/youtube/__init__.py +1 -0
- unread-0.1.0/unread/youtube/commands.py +600 -0
- unread-0.1.0/unread/youtube/metadata.py +102 -0
- unread-0.1.0/unread/youtube/paths.py +54 -0
- unread-0.1.0/unread/youtube/transcript.py +350 -0
- unread-0.1.0/unread/youtube/urls.py +86 -0
- unread-0.1.0/uv.lock +1384 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: unread-dev
|
|
3
|
+
description: |
|
|
4
|
+
Contribute code to the `unread` Python package (github.com/maxbolgarin/unread):
|
|
5
|
+
the local Python CLI that pulls Telegram chats, transcribes voice, analyzes
|
|
6
|
+
with OpenAI, answers questions over the synced corpus (`unread ask`), and
|
|
7
|
+
manages the local DB (backup/restore, doctor, prune). Use this skill when
|
|
8
|
+
editing files under `unread/`, adding a new CLI command or flag, writing
|
|
9
|
+
/ modifying a preset, changing the cache or pipeline, fixing a bug, or
|
|
10
|
+
writing tests in that repo — or when the user says "in unread",
|
|
11
|
+
"the Telegram analyzer repo", or references `unread/cli.py`,
|
|
12
|
+
`analyzer/pipeline.py`, `db/repo.py`, `ask/`, or `interactive.py`.
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# unread — contributor guide
|
|
16
|
+
|
|
17
|
+
Python 3.11+, uv, Typer, Telethon, OpenAI, aiosqlite. **Every OpenAI call is
|
|
18
|
+
async; every DB call is async. No synchronous I/O in command paths.**
|
|
19
|
+
|
|
20
|
+
## 1. First check before writing code
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd ~/path/to/unread # config + storage are cwd-relative
|
|
24
|
+
uv sync --extra dev # if not already done
|
|
25
|
+
uv run pytest -q # baseline: should be all green
|
|
26
|
+
uv run ruff check . # lint clean
|
|
27
|
+
uv run ruff format --check . # format clean
|
|
28
|
+
unread doctor # validates Telegram session, OpenAI key, ffmpeg, DB integrity, pricing
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Before claiming work is done, run the first three — that's what CI runs on Python 3.11, 3.12, and 3.13.
|
|
32
|
+
|
|
33
|
+
## 2. Layout map
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
unread/
|
|
37
|
+
cli.py Typer app — every CLI command + help panels.
|
|
38
|
+
Wraps `_run = asyncio.run`. Lazy imports inside handlers
|
|
39
|
+
keep `unread --help` ~100 ms.
|
|
40
|
+
interactive.py Wizard pickers (questionary + prompt_toolkit).
|
|
41
|
+
Step graph: chat → thread → preset → period → enrich → output → mark_read → confirm.
|
|
42
|
+
`_expand_printable_for_search()` extends `string.printable`
|
|
43
|
+
at module import so non-Latin type-to-filter works.
|
|
44
|
+
config.py Settings loader (.env + config.toml → Pydantic with
|
|
45
|
+
extra="forbid"). `_StrictCfg` base for every block.
|
|
46
|
+
BOM-safe `_load_dotenv`. New: `AskCfg`, `high_impact_reactions`.
|
|
47
|
+
models.py Dataclasses: Message, Chat, Subscription, ResolvedRef…
|
|
48
|
+
core/
|
|
49
|
+
pipeline.py prepare_chat_run() — shared prefix for analyze/dump/download-media
|
|
50
|
+
(resolve → backfill → iter → per-topic unread filter → enrich).
|
|
51
|
+
run.py PreparedRun dataclass (handoff between prefix + consumer).
|
|
52
|
+
paths.py slugify, chat_slug, topic_slug, compute_window (UTC),
|
|
53
|
+
parse_ymd (UTC), derive_internal_id, has_explicit_period.
|
|
54
|
+
db/
|
|
55
|
+
repo.py Single Repo class. All DB access goes through it.
|
|
56
|
+
schema.sql Source of truth; idempotently applied on every Repo.open.
|
|
57
|
+
`Repo._apply_additive_migrations()` adds known missing columns
|
|
58
|
+
for older local DBs; destructive changes still require
|
|
59
|
+
"delete data.sqlite + re-sync".
|
|
60
|
+
tg/
|
|
61
|
+
client.py Telethon client factory (session at storage/session.sqlite.session).
|
|
62
|
+
resolver.py @name / link / fuzzy / numeric id → ResolvedRef.
|
|
63
|
+
sync.py backfill() — incremental fetch + Rich progress bar
|
|
64
|
+
(determinate when from_msg_id+forward, indeterminate otherwise).
|
|
65
|
+
dialogs.py list_unread_dialogs(); correct_forum_unread() helper —
|
|
66
|
+
fixes Telegram's stale dialog-level forum counts via
|
|
67
|
+
GetForumTopicsRequest.
|
|
68
|
+
topics.py folders.py Forum topics + dialog-filter (chat folder) helpers.
|
|
69
|
+
`chat_folder_index(client) -> dict[chat_id, list[folder_titles]]`.
|
|
70
|
+
links.py Telegram link parsing / template building.
|
|
71
|
+
commands.py `unread chats add/list/...` handlers + `cmd_doctor`.
|
|
72
|
+
`cmd_doctor` checks: env files, secrets, ffmpeg, disk,
|
|
73
|
+
DB integrity, Telegram session, OpenAI reachability,
|
|
74
|
+
presets, chat AND audio pricing coverage.
|
|
75
|
+
analyzer/
|
|
76
|
+
prompts.py Preset loader + _base.md composer; BASE_VERSION cache-bust knob.
|
|
77
|
+
name == filename stem enforced; placeholder dry-run validation.
|
|
78
|
+
filters.py FilterOpts(min_msg_chars, include_transcripts, text_only,
|
|
79
|
+
sender_substring, sender_id). filter_messages applies all.
|
|
80
|
+
chunker.py Token-aware chunking. Raises on degenerate budgets
|
|
81
|
+
(< 2000 tokens) instead of silent clamp-to-500.
|
|
82
|
+
formatter.py Render messages for the LLM prompt. `_high_impact_marker`
|
|
83
|
+
prefixes [high-impact] for messages whose reaction sum
|
|
84
|
+
≥ settings.analyze.high_impact_reactions.
|
|
85
|
+
hasher.py batch_hash / reduce_hash / text_hash — cache keys.
|
|
86
|
+
openai_client.py chat_complete() with retry-on-truncation. Uses
|
|
87
|
+
max_completion_tokens (NOT max_tokens — deprecated).
|
|
88
|
+
pipeline.py run_analysis() — map-reduce orchestration with progress bar.
|
|
89
|
+
Public `estimate_cost(n_messages, preset, settings)` used by
|
|
90
|
+
--max-cost / --dry-run / wizard cost preview.
|
|
91
|
+
`AVG_TOKENS_PER_MSG = 60` (Cyrillic-heavy estimate).
|
|
92
|
+
commands.py `unread analyze` + batch flow + output rendering.
|
|
93
|
+
Helpers: `_expand_citations`, `_self_check`, `_post_to_chat`,
|
|
94
|
+
`_post_to_saved_messages` shim, `_split_for_telegram`,
|
|
95
|
+
`_CITATION_RE`.
|
|
96
|
+
ask/ `unread ask` — Q&A across the synced corpus.
|
|
97
|
+
commands.py cmd_ask + _run_single_turn (one Q→A iteration);
|
|
98
|
+
handles the post-answer follow-up loop / --semantic /
|
|
99
|
+
--rerank / --build-index / --refresh / --show-retrieved /
|
|
100
|
+
--max-cost.
|
|
101
|
+
retrieval.py Keyword LIKE retrieval over text||transcript;
|
|
102
|
+
return_scores=True returns list[(Message, int)].
|
|
103
|
+
rerank.py Cheap-model 1-5 ratings; tolerant `_parse_ratings`.
|
|
104
|
+
On total batch failure, returns keyword-sorted pool.
|
|
105
|
+
embeddings.py OpenAI text-embedding-3-small index (build_index +
|
|
106
|
+
semantic_search). Vectors stored as
|
|
107
|
+
array.array('f').tobytes() in message_embeddings.
|
|
108
|
+
Cosine in pure Python (stdlib `math`, no numpy).
|
|
109
|
+
enrich/ Per-kind enrichers run BEFORE filtering:
|
|
110
|
+
pipeline.py enrich_messages() orchestrator + Rich Progress bar +
|
|
111
|
+
per-doc_id asyncio.Lock to dedup parallel work.
|
|
112
|
+
audio.py voice/videonote/video → Whisper transcript (media_enrichments).
|
|
113
|
+
Logs include chat_id / msg_id / msg_date.
|
|
114
|
+
image.py photo → vision-model description.
|
|
115
|
+
document.py pdf/docx/txt/code → local extraction (no OpenAI).
|
|
116
|
+
link.py URL fetch + filter_model summary (link_enrichments).
|
|
117
|
+
Per-message inline summary, NOT full-article analysis —
|
|
118
|
+
see `unread/website/` for that.
|
|
119
|
+
video.py Thin wrapper over audio.py.
|
|
120
|
+
base.py EnrichOpts / EnrichResult / EnrichStats.
|
|
121
|
+
youtube/ Non-Telegram source: `unread analyze <youtube-url>`.
|
|
122
|
+
urls.py is_youtube_url + extract_video_id (cheap stdlib check).
|
|
123
|
+
metadata.py YoutubeMetadata dataclass + yt-dlp metadata fetch.
|
|
124
|
+
transcript.py captions (VTT) → text + timed cues, OR audio download
|
|
125
|
+
+ Whisper segmentation. TranscriptResult shape.
|
|
126
|
+
paths.py reports/youtube/<channel>/<video>-<preset>-<ts>.md.
|
|
127
|
+
commands.py cmd_analyze_youtube — synth-message build, dispatch to
|
|
128
|
+
run_analysis with source_kind="video", link_template
|
|
129
|
+
"?t={msg_id}s" so citations jump to the moment.
|
|
130
|
+
Cache row in `youtube_videos` (metadata + transcript +
|
|
131
|
+
timed_cues_json).
|
|
132
|
+
website/ Non-Telegram source: `unread analyze <website-url>`.
|
|
133
|
+
urls.py is_website_url + is_telegram_url (so t.me/... still
|
|
134
|
+
flows to the resolver) + normalize_url (drops tracking
|
|
135
|
+
params) + page_id (sha256(normalized_url)[:16]) +
|
|
136
|
+
domain_of.
|
|
137
|
+
metadata.py WebsiteMetadata dataclass.
|
|
138
|
+
content.py fetch_page (httpx) + trafilatura extractor with BS4
|
|
139
|
+
fallback (semantic-tag pass, then whole-body get_text).
|
|
140
|
+
_segment_paragraphs splits at blank lines, then
|
|
141
|
+
sentences, then hard-cut. _SPA_MARKERS +
|
|
142
|
+
_explain_empty_extraction produce a tailored error
|
|
143
|
+
for JS-rendered SPAs vs short non-SPA vs large-but-empty.
|
|
144
|
+
paths.py reports/website/<domain>/<page>-<preset>-<ts>.md.
|
|
145
|
+
commands.py cmd_analyze_website — synth-message build, dispatch to
|
|
146
|
+
run_analysis with source_kind="website", link_template
|
|
147
|
+
= page URL (no fragment). Cache row in `website_pages`
|
|
148
|
+
(metadata + paragraphs_json + content_hash).
|
|
149
|
+
media/ Raw media download + `unread download-media` (deprecated;
|
|
150
|
+
folded into `dump --save-media`).
|
|
151
|
+
export/ `unread dump` (md/jsonl/csv).
|
|
152
|
+
util/ flood, logging, pricing, tokens (tiktoken wrapper).
|
|
153
|
+
presets/ Per-language preset trees: presets/<lang>/*.md.
|
|
154
|
+
ru/ RU bodies + _base.md + _reduce.md.
|
|
155
|
+
en/ EN bodies + _base.md + _reduce.md.
|
|
156
|
+
Bundled in each lang: summary, broad, digest, action_items,
|
|
157
|
+
decisions, highlights, links, questions, quotes, reactions,
|
|
158
|
+
single_msg, multichat. Active language picked by
|
|
159
|
+
settings.locale.language (default "en").
|
|
160
|
+
unread/i18n.py User-visible strings keyed by language. `t(key, lang=None)`
|
|
161
|
+
resolves from settings at call time. Add new strings here,
|
|
162
|
+
NOT inline in module code.
|
|
163
|
+
unread/settings/ `unread settings` command. DB-backed overrides for
|
|
164
|
+
commands.py [locale] / [openai] config blocks. Persisted in the
|
|
165
|
+
`app_settings` SQLite table; applied on every
|
|
166
|
+
`open_repo()` via `db/repo.py:_apply_db_overrides`.
|
|
167
|
+
Allow-list of overlay keys: `_OVERRIDE_KEYS`.
|
|
168
|
+
tests/ pytest-asyncio auto mode; all tests live here.
|
|
169
|
+
`test_regressions_deep_review.py` is the "bugs locked in"
|
|
170
|
+
file — every audit / bug-fix lands a regression test there.
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## 3. Invariants — DO NOT VIOLATE
|
|
174
|
+
|
|
175
|
+
These were debugged into existence. Breaking them causes silent data corruption or wasted spend.
|
|
176
|
+
|
|
177
|
+
1. **Never cache truncated results.** `pipeline._call_cached` gates `cache_put` on `not res.truncated`. The cache row also has a `truncated` column, and `_call_cached` re-runs on a hit with `truncated=1`.
|
|
178
|
+
|
|
179
|
+
2. **`build_messages` order is `system → static_context → dynamic`.** `openai_client.build_messages` enforces this. OpenAI's prompt cache only hits when the stable prefix leads.
|
|
180
|
+
|
|
181
|
+
3. **Bump `prompt_version`** in a preset's frontmatter when its body changes. Without the bump, users get stale cached results.
|
|
182
|
+
|
|
183
|
+
4. **Bump `BASE_VERSION`** (in `analyzer/prompts.py`, currently `"v4"`) when editing `presets/<lang>/_base.md` (any language) or the per-language `_FORUM_CONTEXT` constants or `compose_system_prompt`'s structural behavior. Busts `analysis_cache` for **every** preset at once.
|
|
184
|
+
|
|
185
|
+
5. **`redact_old_messages` WHERE clause must include "IS NOT NULL".** Otherwise rowcount reports age-matching rows, not rows actually changed. Regression test in `tests/test_repo_cache.py`.
|
|
186
|
+
|
|
187
|
+
6. **Use `max_completion_tokens`, not `max_tokens`.** Required on gpt-5+ and reasoning models. `openai_client._completion` already does this.
|
|
188
|
+
|
|
189
|
+
7. **Unread-default anchor is `read_inbox_max_id`.** Batch flow (`_run_no_ref` in analyzer + dump) and `--folder` filter rely on `msg_id > read_inbox_max_id`.
|
|
190
|
+
|
|
191
|
+
8. **Config + storage are cwd-relative.** `config.py` resolves `.env`, `config.toml`, and `storage/` from `Path.cwd()`. Don't hardcode install-dir paths.
|
|
192
|
+
|
|
193
|
+
9. **New run-style commands go through `core/pipeline.prepare_chat_run`** *unless* the source isn't a Telegram chat (YouTube, websites, future RSS / eBook / etc.). For non-Telegram sources, dispatch BEFORE `prepare_chat_run` from `cmd_analyze`, build synthetic `Message` rows with `chat_id=0`, and call `run_analysis` directly with `source_kind` + `link_template_override`. Both `youtube/commands.py` and `website/commands.py` follow this pattern — copy that shape, don't re-implement the Telegram prefix.
|
|
194
|
+
|
|
195
|
+
10. **`AnalysisOptions.options_payload` must include every option that affects analysis output.** Adding a new flag that changes the prompt or the message set without updating `options_payload` silently returns stale cached results. Recent additions: `sender_substring`, `sender_id`, `enrich_options` block, `youtube_video_id`, **`website_page_id` + `website_content_hash`**, `source_kind`. For mutable sources (websites — pages can be edited live), the **content hash** is what makes "fetch unchanged → cache hit, fetch changed → cache miss" work; pure URL keying would silently return stale analyses of edited articles.
|
|
196
|
+
|
|
197
|
+
11. **`schema.sql` statements use `IF NOT EXISTS`.** Re-applying schema on every connect is the design — `tests/test_regressions_deep_review.py:test_schema_apply_is_idempotent` enforces it. Destructive changes (column type changes, drops) need explicit handling in `_apply_additive_migrations` plus a documented "delete `data.sqlite` + re-sync" path.
|
|
198
|
+
|
|
199
|
+
12. **`unread ask` semantic scores map to `[0, 100]`.** Cosine `[-1, 1]` is mapped via `(s + 1) * 50` so rerank's "default 0 for unrated" sort works for negative similarities. Don't shortcut to `round(s * 100)`.
|
|
200
|
+
|
|
201
|
+
13. **Citations regex `_CITATION_RE` matches `[^)]+` for the URL.** The LLM is instructed to emit Telegram URLs (no parens). Wikipedia-style URLs with parens would truncate; that's a documented limitation, not a regression.
|
|
202
|
+
|
|
203
|
+
14. **Presets live under `presets/<language>/`.** The loader does not fall back across languages — each language is autonomous. Adding a new user-visible string → put English+native entries in `unread/i18n.py:_STRINGS`, never inline in module code. Both `language` and `content_language` must enter `AnalysisOptions.options_payload` and `Repo.put_last_run_args` payload.
|
|
204
|
+
|
|
205
|
+
15. **`language` and `content_language` have non-overlapping responsibilities.** `language` = UI / saved-report headings only (wizard, `## Sources`, `## Verification`, truncation banner, report metadata). `content_language` = prompts / LLM input (which `presets/<lang>/` directory the loader reads, image+link enricher prompts, ask system prompt, formatter labels in the LLM input, verification audit prompt). When in doubt: anything the LLM sees → `content_language`; anything ONLY the user sees → `language`.
|
|
206
|
+
|
|
207
|
+
16. **Persistent settings overlay applies via `open_repo`.** Adding a new override key requires extending three places together: `db/repo.py:_OVERRIDE_KEYS`, `db/repo.py:_apply_db_overrides` (with the actual settings mutation), and `unread/settings/commands.py:_KNOWN_KEYS` (so `unread settings set <key>` validates). Don't apply overrides anywhere except `_apply_db_overrides` — keeping the surface small avoids drift.
|
|
208
|
+
|
|
209
|
+
## 4. Patterns to follow
|
|
210
|
+
|
|
211
|
+
### Async tests with real SQLite
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from pathlib import Path
|
|
215
|
+
import pytest
|
|
216
|
+
from unread.db.repo import Repo
|
|
217
|
+
|
|
218
|
+
@pytest.fixture
|
|
219
|
+
async def repo(tmp_path: Path) -> Repo:
|
|
220
|
+
r = await Repo.open(tmp_path / "t.sqlite")
|
|
221
|
+
yield r
|
|
222
|
+
await r.close()
|
|
223
|
+
|
|
224
|
+
async def test_x(repo: Repo) -> None:
|
|
225
|
+
... # pytest-asyncio auto mode; no @pytest.mark.asyncio needed
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`pytest-asyncio` auto mode is enabled in `pyproject.toml`. Never introduce in-memory stubs for the DB layer — every repo test uses a real temp SQLite via `tmp_path`.
|
|
229
|
+
|
|
230
|
+
### Stubbing OpenAI (no network in tests)
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from unread.analyzer import openai_client
|
|
234
|
+
|
|
235
|
+
async def test_retry(monkeypatch):
|
|
236
|
+
async def fake_completion(oai, model, messages, max_tokens, temperature):
|
|
237
|
+
# return a minimal fake response with .choices[0].message.content,
|
|
238
|
+
# .choices[0].finish_reason, and .usage.prompt_tokens/completion_tokens
|
|
239
|
+
...
|
|
240
|
+
monkeypatch.setattr(openai_client, "_completion", fake_completion)
|
|
241
|
+
# ...call chat_complete and assert
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
For `ask` rerank tests, stub `unread.ask.rerank.chat_complete` directly — see `test_rerank_total_failure_returns_keyword_sorted_pool`.
|
|
245
|
+
|
|
246
|
+
### Typer command stub (keeps `unread --help` fast)
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
@app.command(rich_help_panel=PANEL_MAIN)
|
|
250
|
+
def newcmd(...) -> None:
|
|
251
|
+
"""One-line help."""
|
|
252
|
+
from unread.analyzer.commands import cmd_newcmd # lazy
|
|
253
|
+
_run(cmd_newcmd(...))
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
For sub-commands, register on the existing Typer instances: `chats_app`, `cache_app`, `reports_app`. Don't import Telethon/OpenAI/heavy modules at module top of `cli.py`.
|
|
257
|
+
|
|
258
|
+
### Lint exceptions — don't "fix" them
|
|
259
|
+
|
|
260
|
+
`pyproject.toml` intentionally suppresses these (read the comment block there):
|
|
261
|
+
|
|
262
|
+
- `RUF001 / RUF002` — em-dashes and `×` in Russian UX strings
|
|
263
|
+
- `PLC0415` — lazy imports in CLI command bodies
|
|
264
|
+
- `B008` — `typer.Option(...)` in function defaults
|
|
265
|
+
- `PLW0603` — settings singleton uses module-level global
|
|
266
|
+
- `E501` — long lines in SQL strings and log messages
|
|
267
|
+
- `PLR09xx` — intentionally flat functions
|
|
268
|
+
|
|
269
|
+
`RUF003` (ambiguous chars in *comments*) is still enforced. Use `*`, not `×`, in comments.
|
|
270
|
+
|
|
271
|
+
## 5. Preset format (`presets/<lang>/*.md`)
|
|
272
|
+
|
|
273
|
+
Presets live under per-language directories (`presets/en/`, `presets/ru/`).
|
|
274
|
+
The active language is `settings.locale.language` (default `"en"`); the
|
|
275
|
+
loader does NOT fall back across languages — each language is autonomous.
|
|
276
|
+
Use `prompts.get_presets(language)` to get the dict for a specific
|
|
277
|
+
language; `prompts.PRESETS` is a backward-compat lazy proxy that resolves
|
|
278
|
+
to the active locale's tree at access time.
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
---
|
|
282
|
+
name: summary # MUST match filename stem (load-time check)
|
|
283
|
+
prompt_version: v2 # BUMP when prompt body changes (per-language; bump only the language(s) you edited)
|
|
284
|
+
description: One-liner shown by the wizard's preset picker # NEW — optional
|
|
285
|
+
needs_reduce: true # false → skip map-reduce even on long inputs
|
|
286
|
+
filter_model: gpt-5.4-nano # map-phase model
|
|
287
|
+
final_model: gpt-5.4-mini # reduce / single-chunk model (note: default is mini, not flagship)
|
|
288
|
+
output_budget_tokens: 4000 # reduce / single-chunk max_tokens
|
|
289
|
+
map_output_tokens: 1500 # per-chunk map max_tokens
|
|
290
|
+
enrich: [link, image] # optional — declares this preset's enrichment needs
|
|
291
|
+
---
|
|
292
|
+
<system prompt goes here>
|
|
293
|
+
---USER---
|
|
294
|
+
<user template — must contain {period} {title} {msg_count} {messages}>
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Files starting with `_` (`_reduce.md`, `_base.md`) are helpers, not presets. Missing `---USER---` → whole body becomes system, `DEFAULT_USER_TAIL[language]` is used. Missing standard placeholders are auto-appended; **typo placeholders fail at load time** with a readable error (see `_validate_user_template`). Name vs filename stem mismatch also fails at load time.
|
|
298
|
+
|
|
299
|
+
**Adding a new preset:** drop the `.md` under `presets/<lang>/` with `description:` set in the frontmatter; the wizard picks it up automatically. Translate to other supported languages.
|
|
300
|
+
|
|
301
|
+
**When to bump `output_budget_tokens`:** if a user reports the "⚠ Output truncated" banner. Also bump `prompt_version` (otherwise the cached truncated-first / fine-later mix gets reused).
|
|
302
|
+
|
|
303
|
+
## 6. Adding a new CLI command — checklist
|
|
304
|
+
|
|
305
|
+
1. **Handler in `unread/<subpkg>/commands.py`** — `async def cmd_newcmd(...)`, uses `open_repo` + `tg_client` async context managers.
|
|
306
|
+
2. **If it's a run-style command** (resolve chat → fetch messages → do X): use `core/pipeline.prepare_chat_run`.
|
|
307
|
+
3. **Typer stub in `cli.py`** — lazy import, match `@app.command(rich_help_panel=PANEL_*)`. Pick the right panel.
|
|
308
|
+
4. **If the command costs money**: add `--max-cost` (use `analyzer.pipeline.estimate_cost` or `count_tokens + chat_cost`) and `--dry-run`.
|
|
309
|
+
5. **If the command's flags affect analysis output**: extend `AnalysisOptions.options_payload` so the cache key reflects them.
|
|
310
|
+
6. **Tests**: unit-test pure logic — don't test Typer wiring itself.
|
|
311
|
+
7. **Update README.md** if user-facing.
|
|
312
|
+
8. **Update `unread-usage` skill** so future sessions surface the new command.
|
|
313
|
+
9. **Update CLAUDE.md** if you've added a new invariant.
|
|
314
|
+
|
|
315
|
+
## 7. Adding a new top-level config block
|
|
316
|
+
|
|
317
|
+
1. New `_StrictCfg` subclass in `config.py`.
|
|
318
|
+
2. Add as a `Field(default_factory=…)` on `Settings`.
|
|
319
|
+
3. Reference via `get_settings().<section>.<field>`.
|
|
320
|
+
4. Document in `config.toml.example` (commented-out, showing default).
|
|
321
|
+
5. `_StrictCfg` inheritance + `Settings.model_config = SettingsConfigDict(extra="forbid")` makes typos fail loudly.
|
|
322
|
+
|
|
323
|
+
## 8. Rerunning the user's request — `--repeat-last` plumbing
|
|
324
|
+
|
|
325
|
+
`Repo.put_last_run_args(chat_id, thread_id, args_dict)` persists a JSON-safe slice of cmd_analyze's flags after every successful run. `Repo.get_last_run_args(chat_id, thread_id=0)` reads them back. The `--repeat-last` block in `cmd_analyze` overrides default-shaped values with saved ones; explicit CLI flags still win.
|
|
326
|
+
|
|
327
|
+
When adding a new flag that should participate in `--repeat-last`:
|
|
328
|
+
1. Add to the dict written by `repo.put_last_run_args(...args=...)` in `_run_single`.
|
|
329
|
+
2. Add a "default-shaped → use saved" branch in the `--repeat-last` overlay in `cmd_analyze`.
|
|
330
|
+
3. Be explicit about JSON-safety — Path, datetime, EnrichOpts must flatten to scalars.
|
|
331
|
+
|
|
332
|
+
## 9. Schema additions
|
|
333
|
+
|
|
334
|
+
Add the table at the bottom of `db/schema.sql` with `IF NOT EXISTS`. For columns on existing tables, add to `_apply_additive_migrations` in `db/repo.py` so older DBs catch up. Recent additions:
|
|
335
|
+
|
|
336
|
+
- `chat_last_run_args(chat_id, thread_id, args_json, updated_at)` — backs `--repeat-last`.
|
|
337
|
+
- `message_embeddings(chat_id, msg_id, model, vector BLOB, created_at)` — backs `unread ask --semantic`.
|
|
338
|
+
- `analysis_cache.truncated INTEGER NOT NULL DEFAULT 0` (additive on existing rows).
|
|
339
|
+
- `messages.reactions TEXT` (additive on existing rows).
|
|
340
|
+
- `youtube_videos(video_id, …, transcript, transcript_timed_json, …)` — backs `unread analyze <youtube-url>`.
|
|
341
|
+
- `website_pages(page_id, url, paragraphs_json, content_hash, extractor, …)` — backs `unread analyze <website-url>`. `page_id = sha256(normalized_url)[:16]`; `content_hash = sha256(joined paragraphs)[:32]` flows into `AnalysisOptions.options_payload`.
|
|
342
|
+
|
|
343
|
+
Test idempotency in `test_regressions_deep_review.py:test_schema_apply_is_idempotent` and additive migrations in `test_schema_apply_upgrades_legacy_tables`.
|
|
344
|
+
|
|
345
|
+
## 10. Cost guard / budget pattern
|
|
346
|
+
|
|
347
|
+
```python
|
|
348
|
+
from unread.analyzer.pipeline import estimate_cost
|
|
349
|
+
|
|
350
|
+
lo, hi = estimate_cost(n_messages=N, preset=preset, settings=get_settings())
|
|
351
|
+
if hi is not None and hi > max_cost:
|
|
352
|
+
if yes:
|
|
353
|
+
raise typer.Exit(2)
|
|
354
|
+
if not typer.confirm(f"Estimate ${lo:.4f}–${hi:.4f} > ${max_cost}; run anyway?"):
|
|
355
|
+
raise typer.Exit(0)
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
For `ask`-shape commands (no map-reduce), use `count_tokens(prompt, model) + chat_cost(model, prompt_tokens, 0, max_tokens)` — see `ask/commands.py:_run_single_turn`.
|
|
359
|
+
|
|
360
|
+
## 11. Citation expansion / audit pattern
|
|
361
|
+
|
|
362
|
+
`_expand_citations(body, *, chat_id, repo, context_n, thread_id, cap=30)` — regex-parses `[#msg_id](url)` citations, looks up surrounding messages via `Repo.get_messages_around`, appends `<details>` blocks. Cap exists to prevent a runaway LLM from 10×-ing the report file. Backticks in body text are escaped to avoid breaking the inline `[ts #msg_id]` code span.
|
|
363
|
+
|
|
364
|
+
`_self_check(result, messages, repo)` — cheap-model audit pass; appends `## Verification` to the report. Best-effort; failure returns empty string and the run continues.
|
|
365
|
+
|
|
366
|
+
Both run between `run_analysis` and `_print_and_write` in `_run_single`. They mutate `result.final_result` so downstream rendering / posting / `--post-saved` see the augmented body.
|
|
367
|
+
|
|
368
|
+
## 12. Posting analyses to Telegram
|
|
369
|
+
|
|
370
|
+
`_post_to_chat(client, repo, result, *, title, target)` — resolves any chat ref via `tg/resolver.resolve` and sends. `me` / `saved` / empty target → `client.get_me()`. `_split_for_telegram` chunks on paragraph → line → hard-cut to stay under Telegram's 4000-char per-message ceiling. Failures warn but don't crash the run (the report is already on disk).
|
|
371
|
+
|
|
372
|
+
`_post_to_saved_messages` is a back-compat shim that calls `_post_to_chat(target="me")` so legacy `--post-saved` plumbing keeps working without threading `repo` through.
|
|
373
|
+
|
|
374
|
+
## 13. `unread ask` retrieval pipeline
|
|
375
|
+
|
|
376
|
+
```
|
|
377
|
+
cmd_ask
|
|
378
|
+
├─ tokenize (bilingual stop-words)
|
|
379
|
+
├─ optional --refresh: backfill scoped chats
|
|
380
|
+
├─ optional --build-index: build embeddings, exit
|
|
381
|
+
├─ retrieve_messages (keyword) OR semantic_search (cosine)
|
|
382
|
+
├─ optional rerank (cheap-model 1-5 ratings)
|
|
383
|
+
├─ format_messages with per-chat link templates
|
|
384
|
+
├─ count_tokens cost preview / --max-cost guard
|
|
385
|
+
├─ chat_complete (multi-turn message history during the post-answer follow-up loop)
|
|
386
|
+
└─ render or save
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
When adding a flag that affects retrieval (limit, scope, model): respect rerank's `keep` logic — `candidate_limit = max(limit, ask_cfg.rerank_top_k)` if rerank is on. Re-sort chronologically after rerank for the LLM context.
|
|
390
|
+
|
|
391
|
+
For the post-answer follow-up loop: `_build_history_messages(system, history, new_user_text)` produces `[system, user, assistant, …, user]`. System prompt stays at index 0 so prompt-cache hits on the first turn are byte-identical to single-shot.
|
|
392
|
+
|
|
393
|
+
Use `phase=ask` in `chat_complete` context for cost attribution.
|
|
394
|
+
|
|
395
|
+
## 14. Wizard step graph — don't break it
|
|
396
|
+
|
|
397
|
+
Order: `chat → thread (forum) → preset (analyze) → period → enrich → output → mark_read → confirm`. Period before enrich is deliberate — the enrich picker shows period-scoped media counts via `Repo.media_breakdown(...)` keyed by `_period_to_db_filters(period, custom_since, custom_until, custom_from_msg, chat)`.
|
|
398
|
+
|
|
399
|
+
When adding a step:
|
|
400
|
+
- BACK transitions in `output` and `mark_read` go to `enrich` (or to `preset`/`chat` for run-on-all).
|
|
401
|
+
- ESC must be wired via `_bind_escape(question, BACK)` to return BACK; first step uses `None` (cancel).
|
|
402
|
+
- Folder column in pickers comes from `tg/folders.chat_folder_index(client)` — re-fetch when relevant.
|
|
403
|
+
|
|
404
|
+
`_expand_printable_for_search()` runs at module import and extends `string.printable` with Cyrillic / Greek / Arabic / Hebrew / Latin Extended ranges. CJK is excluded on purpose (would register tens of thousands of bindings).
|
|
405
|
+
|
|
406
|
+
## 15. Before opening a PR — the CI-equivalent local check
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
uv run pytest -q # must be 100% green; no skips without a reason
|
|
410
|
+
uv run ruff check . # must be "All checks passed!"
|
|
411
|
+
uv run ruff format --check . # must report "N files already formatted"
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
These three are exactly what `.github/workflows/ci.yml` runs on Python 3.11, 3.12, 3.13. CI caches `uv.lock` — **`uv.lock` must be committed** (history shows a CI failure from this exact footgun).
|
|
415
|
+
|
|
416
|
+
Run `unread doctor` after any DB / config / dependency change.
|
|
417
|
+
|
|
418
|
+
When in doubt, check `CLAUDE.md` at the repo root — it's the canonical pointer to invariants and anti-patterns. `ROADMAP.md` tracks deferred work; `tests/test_regressions_deep_review.py` is the running list of bugs locked in by tests.
|