second-read 0.1.1__py3-none-any.whl

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.
Files changed (95) hide show
  1. second_read-0.1.1.dist-info/METADATA +243 -0
  2. second_read-0.1.1.dist-info/RECORD +95 -0
  3. second_read-0.1.1.dist-info/WHEEL +4 -0
  4. second_read-0.1.1.dist-info/entry_points.txt +4 -0
  5. second_read-0.1.1.dist-info/licenses/LICENSE +21 -0
  6. secondread/__init__.py +0 -0
  7. secondread/agents/__init__.py +0 -0
  8. secondread/agents/copyedit/__init__.py +10 -0
  9. secondread/agents/copyedit/agent.py +261 -0
  10. secondread/agents/copyedit/prompts.py +123 -0
  11. secondread/agents/copyedit/text.py +274 -0
  12. secondread/agents/devils_advocate/__init__.py +18 -0
  13. secondread/agents/devils_advocate/agent.py +681 -0
  14. secondread/agents/devils_advocate/prompts.py +263 -0
  15. secondread/agents/devils_advocate/schemas.py +86 -0
  16. secondread/agents/devils_advocate/text.py +123 -0
  17. secondread/agents/extractor/__init__.py +20 -0
  18. secondread/agents/extractor/agent.py +330 -0
  19. secondread/agents/extractor/chunks.py +53 -0
  20. secondread/agents/extractor/prompts.py +121 -0
  21. secondread/agents/extractor/spans.py +177 -0
  22. secondread/agents/factcheck/__init__.py +12 -0
  23. secondread/agents/factcheck/agent.py +267 -0
  24. secondread/agents/factcheck/prompts.py +264 -0
  25. secondread/agents/factcheck/research.py +737 -0
  26. secondread/agents/factcheck/schemas.py +124 -0
  27. secondread/agents/factcheck/sources.py +295 -0
  28. secondread/agents/originality/__init__.py +29 -0
  29. secondread/agents/originality/agent.py +277 -0
  30. secondread/agents/originality/sentences.py +311 -0
  31. secondread/agents/originality/similarity.py +114 -0
  32. secondread/agents/structure/__init__.py +25 -0
  33. secondread/agents/structure/agent.py +301 -0
  34. secondread/agents/structure/prompts.py +179 -0
  35. secondread/cli/__init__.py +5 -0
  36. secondread/cli/__main__.py +6 -0
  37. secondread/cli/envfile.py +80 -0
  38. secondread/cli/errors.py +114 -0
  39. secondread/cli/main.py +327 -0
  40. secondread/contracts/__init__.py +134 -0
  41. secondread/contracts/errors.py +60 -0
  42. secondread/contracts/interfaces.py +314 -0
  43. secondread/contracts/models.py +689 -0
  44. secondread/mcp_local/README.md +96 -0
  45. secondread/mcp_local/__init__.py +35 -0
  46. secondread/mcp_local/__main__.py +6 -0
  47. secondread/mcp_local/demo.py +111 -0
  48. secondread/mcp_local/errors.py +107 -0
  49. secondread/mcp_local/main.py +80 -0
  50. secondread/mcp_local/progress.py +86 -0
  51. secondread/mcp_local/server.py +187 -0
  52. secondread/mcp_local/wiring.py +251 -0
  53. secondread/orchestrator/__init__.py +54 -0
  54. secondread/orchestrator/assemble.py +148 -0
  55. secondread/orchestrator/authwatch.py +83 -0
  56. secondread/orchestrator/classify.py +75 -0
  57. secondread/orchestrator/conflicts.py +406 -0
  58. secondread/orchestrator/labels.py +39 -0
  59. secondread/orchestrator/orchestrator.py +601 -0
  60. secondread/orchestrator/planning.py +221 -0
  61. secondread/orchestrator/progress.py +114 -0
  62. secondread/orchestrator/prompts.py +132 -0
  63. secondread/orchestrator/reactions.py +153 -0
  64. secondread/orchestrator/registry.py +56 -0
  65. secondread/orchestrator/schemas.py +45 -0
  66. secondread/pipeline.py +73 -0
  67. secondread/providers/__init__.py +0 -0
  68. secondread/providers/fetch/__init__.py +29 -0
  69. secondread/providers/fetch/extract.py +153 -0
  70. secondread/providers/fetch/fetcher.py +241 -0
  71. secondread/providers/fetch/guarded.py +110 -0
  72. secondread/providers/fetch/ssrf.py +207 -0
  73. secondread/providers/llm/__init__.py +49 -0
  74. secondread/providers/llm/_common.py +259 -0
  75. secondread/providers/llm/anthropic_client.py +332 -0
  76. secondread/providers/llm/config.py +93 -0
  77. secondread/providers/llm/openai_client.py +229 -0
  78. secondread/providers/search/__init__.py +45 -0
  79. secondread/providers/search/base.py +218 -0
  80. secondread/providers/search/brave.py +55 -0
  81. secondread/providers/search/config.py +63 -0
  82. secondread/providers/search/exa.py +62 -0
  83. secondread/providers/search/retry.py +60 -0
  84. secondread/providers/search/tavily.py +60 -0
  85. secondread/registry.py +270 -0
  86. secondread/report/__init__.py +30 -0
  87. secondread/report/email.py +43 -0
  88. secondread/report/html.py +49 -0
  89. secondread/report/markdown.py +195 -0
  90. secondread/report/templates/email.html.j2 +139 -0
  91. secondread/report/text.py +158 -0
  92. secondread/report/view.py +408 -0
  93. secondread/testing/__init__.py +1 -0
  94. secondread/testing/fakes.py +458 -0
  95. secondread/testing/samples.py +348 -0
@@ -0,0 +1,243 @@
1
+ Metadata-Version: 2.5
2
+ Name: second-read
3
+ Version: 0.1.1
4
+ Summary: Evidence-backed document review for Claude: fact-checks claims against the web and builds the strongest counter-argument, as an MCP server.
5
+ Project-URL: Homepage, https://github.com/sbanthia92/second-read
6
+ Project-URL: Repository, https://github.com/sbanthia92/second-read
7
+ Project-URL: Issues, https://github.com/sbanthia92/second-read/issues
8
+ Project-URL: Changelog, https://github.com/sbanthia92/second-read/releases
9
+ Author: Shubham Banthia
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: claude,editing,fact-checking,llm,mcp,review
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Text Processing
18
+ Requires-Python: <3.13,>=3.12
19
+ Requires-Dist: anthropic>=1.10.0
20
+ Requires-Dist: beautifulsoup4>=4.15.0
21
+ Requires-Dist: httpx>=0.28.1
22
+ Requires-Dist: jinja2>=3.1.6
23
+ Requires-Dist: mcp>=2.2.0
24
+ Requires-Dist: openai>=3.22.1
25
+ Requires-Dist: pydantic>=2.13.5
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Second Read
29
+
30
+ Second Read reviews a draft the way a careful editor would, and shows its
31
+ sources. You give it an article, a blog post or a design doc. It returns one
32
+ report:
33
+
34
+ - **Facts checked against the web.** Each claim is looked up, and every
35
+ "this is wrong" comes with the page that says so.
36
+ - **The strongest counter-case.** A devil's advocate builds the best argument
37
+ against your piece from sources it retrieves.
38
+ - **Copy edits.** Grammar and clarity fixes that keep your voice.
39
+ - **Originality notes.** Sentences that closely match text found online.
40
+
41
+ It runs as an [MCP](https://modelcontextprotocol.io) server with one tool,
42
+ `review_document`, so you can ask for a review from Claude Code or Claude
43
+ Desktop. It also has a command-line tool. It runs on your machine with **your
44
+ own API keys**; nothing is sent to the author of this project.
45
+
46
+ A finding looks like this:
47
+
48
+ > **Factual error** — "Mjällby clinched the title on 20 October 2025 by winning
49
+ > 2-0 at IFK Göteborg, with two games to spare"
50
+ >
51
+ > The date, the 2-0 score and the opponent are confirmed. ESPN and Flashscore
52
+ > both say three rounds were left (27 of 30 played), so "two games to spare" is
53
+ > wrong.
54
+
55
+ ## What you need
56
+
57
+ - [uv](https://docs.astral.sh/uv/) (it installs Python 3.12 for you).
58
+ - An [Anthropic](https://platform.claude.com) API key: `ANTHROPIC_API_KEY`.
59
+ - A [Tavily](https://tavily.com) search key: `TAVILY_API_KEY`.
60
+
61
+ ### Getting your keys
62
+
63
+ You need two keys. Each takes a couple of minutes: sign up, create a key, copy
64
+ it.
65
+
66
+ | Key | Where to get it |
67
+ | --- | --- |
68
+ | `ANTHROPIC_API_KEY` | Sign in at <https://platform.claude.com>, add credit under Billing, then create a key under **API keys** (<https://platform.claude.com/settings/keys>) |
69
+ | `TAVILY_API_KEY` | Sign up at <https://app.tavily.com>; the key is shown on the dashboard |
70
+
71
+ The Anthropic key is pay-as-you-go: reviews fail with a clear message if the
72
+ account has no credit. Keep keys out of chats and out of version control; put
73
+ them in the MCP client's `env` block or in a `.env` file as shown below.
74
+
75
+ A review of a 700-word article takes about two minutes and uses roughly
76
+ 150,000–180,000 tokens on your model key (about $0.60–$1.20 at Anthropic's
77
+ list prices in October 2026) plus about 30 searches.
78
+
79
+ ## Install: Claude Code
80
+
81
+ ```sh
82
+ claude mcp add second-read \
83
+ -e ANTHROPIC_API_KEY=your-key \
84
+ -e TAVILY_API_KEY=your-key \
85
+ -- uvx --from git+https://github.com/sbanthia92/second-read secondread-mcp
86
+ ```
87
+
88
+ Start Claude Code with a longer tool timeout, because a review takes minutes:
89
+
90
+ ```sh
91
+ MCP_TOOL_TIMEOUT=600000 claude
92
+ ```
93
+
94
+ Then ask: "Use second-read to review this draft", and paste the text or give a
95
+ public URL.
96
+
97
+ ## Install: Claude Desktop
98
+
99
+ Add this to `claude_desktop_config.json` (on macOS:
100
+ `~/Library/Application Support/Claude/claude_desktop_config.json`), then restart
101
+ the app. If Claude Desktop cannot find `uvx`, use its full path (`which uvx`).
102
+
103
+ ```json
104
+ {
105
+ "mcpServers": {
106
+ "second-read": {
107
+ "command": "uvx",
108
+ "args": ["--from", "git+https://github.com/sbanthia92/second-read", "secondread-mcp"],
109
+ "env": {
110
+ "ANTHROPIC_API_KEY": "your-key",
111
+ "TAVILY_API_KEY": "your-key"
112
+ }
113
+ }
114
+ }
115
+ }
116
+ ```
117
+
118
+ To try it without keys, replace the `env` block with
119
+ `{"SECONDREAD_FAKE": "1"}`. Demo mode returns placeholder findings and makes
120
+ no model calls.
121
+
122
+ ## Command line
123
+
124
+ ```sh
125
+ git clone https://github.com/sbanthia92/second-read && cd second-read
126
+ cp .env.example .env # then put your keys in .env
127
+ uv run secondread review path/to/draft.md --profile opinion > report.md
128
+ ```
129
+
130
+ Progress prints to the terminal; the report goes to `report.md`. Use
131
+ `--profile design_doc` for technical design docs, or leave the profile out to
132
+ let the tool decide. A URL works in place of a file path.
133
+
134
+ ## The tool
135
+
136
+ `review_document` takes:
137
+
138
+ | Argument | Required | Notes |
139
+ | --- | --- | --- |
140
+ | `content` | one of `content` / `url` | The draft as text or markdown (up to 20,000 words) |
141
+ | `url` | one of `content` / `url` | A public web page to fetch and review |
142
+ | `profile` | no | `auto` (default), `opinion` or `design_doc` |
143
+ | `focus` | no | A steer, e.g. "check the financial figures" |
144
+
145
+ It returns the report as markdown and sends progress updates while it works.
146
+
147
+ The report has these sections: a verdict line; **Must fix** (factual errors and
148
+ claims the sources do not back); **Could not verify** (claims it found no
149
+ adequate source for, which is not the same as wrong); **Strongest
150
+ counter-case**; **Should fix** (structure); **Polish** (copy edits);
151
+ **Originality notes**; and an appendix listing every claim with its verdict and
152
+ sources.
153
+
154
+ ## Settings
155
+
156
+ Set these in the MCP client's `env` block, in the environment, or in a `.env`
157
+ file (the server reads `.env` from its working directory, or the file named by
158
+ `SECONDREAD_ENV_FILE`).
159
+
160
+ | Variable | Purpose |
161
+ | --- | --- |
162
+ | `ANTHROPIC_API_KEY` | Anthropic model key |
163
+ | `TAVILY_API_KEY` | Web search key |
164
+ | `SECONDREAD_MODEL_CHEAP`, `_MID`, `_STRONG` | Override the model used for each tier |
165
+ | `SECONDREAD_MAX_WORDS` | Document size cap (default 20000) |
166
+ | `SECONDREAD_FAKE=1` | Demo mode: no keys, no model calls |
167
+
168
+ ## What leaves your machine
169
+
170
+ - **Your document** goes to Anthropic, under your key and their terms.
171
+ - **Search queries** built from your document's claims, and up to eight
172
+ sentences quoted exactly for the originality check, go to Tavily.
173
+ - **Web pages** named in search results are fetched directly from your machine.
174
+ The fetcher refuses private and local network addresses.
175
+
176
+ Nothing is stored after the review finishes, and nothing is sent anywhere
177
+ else. Keys are read from your environment and are never logged.
178
+
179
+ ## How well it works
180
+
181
+ Measured on a small test in October 2026: four articles with planted errors
182
+ that cannot be caught from memory (recent results, exact figures), one run
183
+ each, compared with asking the same model to "review this and argue the other
184
+ side" in a single prompt.
185
+
186
+ | | Single prompt | Second Read |
187
+ | --- | --- | --- |
188
+ | Planted wrong facts caught (19) | 7 | 13 |
189
+ | ...with a cited source | 0 | 13 |
190
+ | Weak arguments rebutted (4) | 2 | 4 |
191
+ | Borrowed sentences caught (3) | 3 | 1 |
192
+ | Tokens per article | about 5,000 | about 136,000 |
193
+
194
+ Second Read earns its cost on claims that need looking up: recent events,
195
+ exact figures, and details a model cannot know from memory. Every finding
196
+ names its source, so you can check it in one click.
197
+
198
+ Getting the most from it:
199
+
200
+ - Open the cited source before acting on a "factual error". The report links
201
+ the page and quotes the passage it relied on.
202
+ - Search results change, so two runs on the same draft can differ slightly.
203
+ - The originality check is a heuristic: it flags close matches found online.
204
+ - Sources are web pages; PDFs are not read.
205
+
206
+ ## How it works
207
+
208
+ An orchestrator runs specialist agents and settles their disagreements:
209
+
210
+ 1. A **claim extractor** splits the document into its thesis, supporting
211
+ claims and checkable facts.
212
+ 2. In parallel: a **fact-checker** researches each fact in a bounded loop
213
+ (search, fetch, read, assess; at most three rounds per claim); a **devil's
214
+ advocate** gathers counter-evidence; a **copy editor**; for articles, an
215
+ **originality checker**; and, for design docs, a **structure reviewer**.
216
+ 3. A reaction round: a verified fact the devil's advocate disputes is
217
+ re-checked, and the two argue once over the most important disagreements.
218
+ 4. Fixed rules settle what is left (for example, a grammar fix on a sentence
219
+ flagged as wrong is dropped), findings are ranked, and the report is written.
220
+
221
+ `DESIGN.md` has the full design. `ARCHITECTURE.md` describes the shared types.
222
+
223
+ ## Development
224
+
225
+ ```sh
226
+ uv sync
227
+ uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest
228
+ ```
229
+
230
+ Tests run offline with fakes. Tests that call real providers are marked `live`
231
+ and skipped by default (`uv run pytest -m live`). The evaluation harness and
232
+ its 40 seeded documents are in `eval/` (see `eval/README.md`); running it
233
+ against real providers spends money on your keys.
234
+
235
+ ## Status
236
+
237
+ The local MCP server and command-line tool work. A hosted version (sign in,
238
+ add a key once, get the report by email) is designed in `DESIGN.md` but not
239
+ built.
240
+
241
+ ## License
242
+
243
+ MIT. See `LICENSE`.
@@ -0,0 +1,95 @@
1
+ secondread/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ secondread/pipeline.py,sha256=6gETxqfrs19BxWVrylqP9zwqo_XI8z-jrAnrRDqSXgM,2380
3
+ secondread/registry.py,sha256=jRRDjqTP5FnUmHNRaCZAfntGLOJ7bf3C8XnZuhUGKTQ,9054
4
+ secondread/agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ secondread/agents/copyedit/__init__.py,sha256=5PtZpcLs3YoFCoaT1yByeFImOV56Wc9cbnBqEW_-iSE,413
6
+ secondread/agents/copyedit/agent.py,sha256=RvOkutqik-diKvQVNtwbth_pf_RFZNn90Hj9YGB34sg,9823
7
+ secondread/agents/copyedit/prompts.py,sha256=dqxXYNwYnPLx7soJGVl3gn1OTpUV3hH3iP8f8iIrFGg,4920
8
+ secondread/agents/copyedit/text.py,sha256=m-VIsjMfLq5ajLERJlqAGyFfhY7LbQlTgqwMJEu1Xrw,8422
9
+ secondread/agents/devils_advocate/__init__.py,sha256=n8JNFnOdldvl4Y9Sma_YxeNh02jQF8Rc5NPRkoWVRNM,600
10
+ secondread/agents/devils_advocate/agent.py,sha256=Khm2ZUJwftpWoLdcXDpiL_W-dulQc6tDckxvInZ01ak,25499
11
+ secondread/agents/devils_advocate/prompts.py,sha256=Q7Gb2P_P6YgS5wnOnPcw97ZgxcoUPWnzeEs9Hw-L1to,10969
12
+ secondread/agents/devils_advocate/schemas.py,sha256=PTG22TP70R9s_iJp7vK2utrR3n8Ve1q60GFeOrzoLBc,2475
13
+ secondread/agents/devils_advocate/text.py,sha256=1fLDWTi0gwyqjzJRF_AEV6JrWFQeKRAphIBmPxi8ypE,4751
14
+ secondread/agents/extractor/__init__.py,sha256=MhkB9fpu_Ynytxoy_wGBHIWarXBhASIvNjWsLa1CmUk,608
15
+ secondread/agents/extractor/agent.py,sha256=5pRTa5YMsiUg7Xx8Tktafh04_psEVYu07YRVF_wn4XY,12003
16
+ secondread/agents/extractor/chunks.py,sha256=rPSeqjCezMRJCJPOys2kL7oQiwo3hiaAeGaM81gqCvQ,1692
17
+ secondread/agents/extractor/prompts.py,sha256=njA0VckeYcLRXYYMbPhHxYuyJCkAP7ZyisXPmkt_e5Q,4896
18
+ secondread/agents/extractor/spans.py,sha256=wv9eqnyeK6sCEYQa6lOOuxhrp5OtSAOIVhMJ-xWqrm4,5652
19
+ secondread/agents/factcheck/__init__.py,sha256=9Ysiqf2par4WEXAx2HVUYJ13Lk5708LkZL4IDFU2lPw,509
20
+ secondread/agents/factcheck/agent.py,sha256=u3__NuNKsP1PrvFn5mqFYj_RgqlNcocs4JcctFrbnAk,10395
21
+ secondread/agents/factcheck/prompts.py,sha256=m7bMivMfDohQ4jUJ4C4CgChnY4UD0AEGMBw1Sntjqsk,11489
22
+ secondread/agents/factcheck/research.py,sha256=hCWfe33bG7UryDtSjIO-ekLmAp9IDXdlA8DLyWkswIo,30678
23
+ secondread/agents/factcheck/schemas.py,sha256=rz9yChibT-ASJLopr6VA3xO-nD3NXcW6sNLWsRbFN7c,4235
24
+ secondread/agents/factcheck/sources.py,sha256=rTb7a_wqMuOKqpRg7E1qdvH9eHUYiGAcqDG08aW_2Kc,10009
25
+ secondread/agents/originality/__init__.py,sha256=BxpkJ8r4zxxbz569gU21gN_oMyRk0pBTJJSFopBGjAY,813
26
+ secondread/agents/originality/agent.py,sha256=f3y2oZH7xwyG6LlRTgEq_xwQOmMrcoGWw-Sgr-mxIWI,10625
27
+ secondread/agents/originality/sentences.py,sha256=XUGbY2kQv8qd8JWiKnoooo0SZ5k2lW9iuYXh3-YaRxI,12015
28
+ secondread/agents/originality/similarity.py,sha256=CbsA3YYSaw2DAvUflkbFZMrzpXsN6pq0r5IgSuvzpCs,3947
29
+ secondread/agents/structure/__init__.py,sha256=hGKKm8_lZfAZuHk97-1hDWejchjYfw9dFUc6WIqySsE,594
30
+ secondread/agents/structure/agent.py,sha256=Cv6mC-OhSGTsfocoJGKYKA9AyNawefiJFONLeoUmlXY,11096
31
+ secondread/agents/structure/prompts.py,sha256=E_sqLyjJBJMdVaixr_YFxxwUR3TagayJoJr3SvqWTlM,6816
32
+ secondread/cli/__init__.py,sha256=uFcPDK9Aj4xH8epXMoXKzrH3g8JXYJkHHhyJDozilLA,203
33
+ secondread/cli/__main__.py,sha256=SXMMPEDxXLTpA0MGWG2fQ_h1xDajxsREybeVxr5zrCI,113
34
+ secondread/cli/envfile.py,sha256=kb7NHuRiUoiEF27ACl9n5-kuq5UqfyURvzyT8Qwh81c,2719
35
+ secondread/cli/errors.py,sha256=kARoCvhEClX9pI6t6SMQlTiWhDdKc5lDK1yUJbiZDwM,3778
36
+ secondread/cli/main.py,sha256=5eD5z1Wgq0tSan9uH_ksHu5dwdX7w6nRsL5WBcbHVEM,11108
37
+ secondread/contracts/__init__.py,sha256=6JmVJvFbpb48sNjrvfcgn9aUXUUfL284abyAJVoNM_g,2388
38
+ secondread/contracts/errors.py,sha256=3mns7OPECQlxfqG_ATYpTiSPMYt3jtOjDAI85zBN4mE,1683
39
+ secondread/contracts/interfaces.py,sha256=_32gMrAzVmXuZqzr8qaAaAv_15edov3CLivCKVuh_HA,9679
40
+ secondread/contracts/models.py,sha256=Kl-2VZel3chf1IfUOnSOdSWr1jxx1dN8ZQVU9FLjR4A,24049
41
+ secondread/mcp_local/README.md,sha256=J_WjBZR-T9AGZ1ptr-nArf2WvfqN2-IG_CjPy7Kyhxw,3298
42
+ secondread/mcp_local/__init__.py,sha256=FvM6WTqAs38XjOnNxum-aQ3vplb4plszNxYz7xfducY,953
43
+ secondread/mcp_local/__main__.py,sha256=_bx-F6huhy6WQa_tjKh2_M36CQwP35QUr2pXOdkBb30,151
44
+ secondread/mcp_local/demo.py,sha256=mVKqtU2cgvv4T5Yox1XlBCrqgLQpSsgy4a5_Q4n2ffQ,3963
45
+ secondread/mcp_local/errors.py,sha256=QZDw_xdUdo6IVLyslbiVapgCHlKi7naZ216pHeQR3a8,3843
46
+ secondread/mcp_local/main.py,sha256=YN2D5ubsqNFiyYsFi-PkFYhUtPim3ZMbcFXxW5r2R-0,3002
47
+ secondread/mcp_local/progress.py,sha256=shmLqQrk45hxftj0Ly9SCl-CGUJsvxrE9M8wiAYogwI,3326
48
+ secondread/mcp_local/server.py,sha256=0yJZBwlVywGKolbPVc0UTqBXd-NcYwtvOuXuprnXPWs,7348
49
+ secondread/mcp_local/wiring.py,sha256=mnMVfNbXMWEwUUu6u2T8x4eyp9pMfTtB7OrC1PBFI8c,7951
50
+ secondread/orchestrator/__init__.py,sha256=UALnVwzpQyQEfeaM59BxErvueURh0dwrDaYQqtYCXMo,1691
51
+ secondread/orchestrator/assemble.py,sha256=AhiuoPns5AA6AmMqfRu_GDDMno56RRSbg9sTI79YbAw,5060
52
+ secondread/orchestrator/authwatch.py,sha256=0BQRiyABQ-vWQUnU5b710C1--Qva-0jV5TmBOZwq_Vc,2443
53
+ secondread/orchestrator/classify.py,sha256=OIVpV3xekuCT3AYTJhVYeGF6sT9-f1xfkSIcIrpiT3U,2812
54
+ secondread/orchestrator/conflicts.py,sha256=SmGA3qs_czH_wlqK10vOXcJjMSPPTaU512rrR5mSo3E,15597
55
+ secondread/orchestrator/labels.py,sha256=qYFfUCHI-whyEo1qWV5ZyuWiWs7hJ7evxg6Ki6ahoUA,1204
56
+ secondread/orchestrator/orchestrator.py,sha256=xV78CWKx088y1EvriBzgZJO0aOXAaONTqNIM9b3jjNk,23599
57
+ secondread/orchestrator/planning.py,sha256=bZ16ro9k-xVfs4FzK8NkFDpZ4kPhE_OegHGwOJtbhUQ,8772
58
+ secondread/orchestrator/progress.py,sha256=nmZh3CO4VjyqD7PwnKbIgBR6TGQTto8nyir7pcKXscg,4208
59
+ secondread/orchestrator/prompts.py,sha256=g_zrHwwzrCuIqpNAabkxiRNoie41TyrJG3OrvcBQZ6Q,5274
60
+ secondread/orchestrator/reactions.py,sha256=4BahoyfcM33GPmM2QVUtQQvEEcY9kYOgDiYEjJL0sb8,5551
61
+ secondread/orchestrator/registry.py,sha256=2EyZNvRcpHzxFh62_TrB2uoHz3cEk-lLVQQ3pLpaMOE,2011
62
+ secondread/orchestrator/schemas.py,sha256=vC6cVild9tkzv2Ljwrl_I4wMrQM9itvHyvuTDiYJWGk,1258
63
+ secondread/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
64
+ secondread/providers/fetch/__init__.py,sha256=o7r8k9MUzxZneeNPZZk3FjXRDCRl0UaW6T5mXgSQYVI,794
65
+ secondread/providers/fetch/extract.py,sha256=sHxAuIkKcXn9bpB5avZlsYFK7ITaeesSloKzx_Y2h6Q,4162
66
+ secondread/providers/fetch/fetcher.py,sha256=ZQFtuUTpxgzXECUgX9xciw0CHk_ggHFgQVXdO5mi4Jk,9440
67
+ secondread/providers/fetch/guarded.py,sha256=jJ5OXIIFGxoC11UeWTTLYZiZi9z_-xLUs4_8cvl4TNE,4192
68
+ secondread/providers/fetch/ssrf.py,sha256=ZJzk8m8Qutvu2HJDNXr_eiD8FJmAXDM4BENj3nXkpuA,7409
69
+ secondread/providers/llm/__init__.py,sha256=jEfd7HZ9rEXg299eG0MUcHrYS_z3JB3ymEPLd-D8uSI,1416
70
+ secondread/providers/llm/_common.py,sha256=yDvwatqwfKbEEGaqAXYiDUL4nZYGnvTmmzCoSJS6eEY,8826
71
+ secondread/providers/llm/anthropic_client.py,sha256=2aLvJoxl8L9YNR68jxxMb3Khb0Bj67G4FMONgRDluQ0,12124
72
+ secondread/providers/llm/config.py,sha256=LBTsjX2uT1Ed1K5Gxb0zdrUQkILGYewGcLLWcfe0TvI,3021
73
+ secondread/providers/llm/openai_client.py,sha256=6c3L1V6FHWzZAnK8UOOfroUdLC3xKNOVOlW4UHQDmxs,8389
74
+ secondread/providers/search/__init__.py,sha256=6O3Q6kNyHLobR123p5GvbOplBWLhI_poGJWB-vIhRGs,1659
75
+ secondread/providers/search/base.py,sha256=2vW3KEVj2xSiWWv8YkMU5_FQuWfUx8Go9BTHudgT2Hw,7576
76
+ secondread/providers/search/brave.py,sha256=BPj8__NoOANiizYYoIEhd4fzCte9AYvDlaBFlUuxBsQ,1703
77
+ secondread/providers/search/config.py,sha256=V17cdSXJyHNhLv_rdq_PZFp4FeQ_IWTxpb2T3nbS8vo,2242
78
+ secondread/providers/search/exa.py,sha256=j_LcLcVLLlCr47U_PRULdI9sSQFuvBcLQ8QcImi8Lh0,2036
79
+ secondread/providers/search/retry.py,sha256=M7s6A7RNoRXGkCAUugwEji0Sci5XGbD_lRLVBy50nVs,2168
80
+ secondread/providers/search/tavily.py,sha256=qVn-3xRIr8A-Cd6_bl5fz_kFRsC_CrR_DzojVtvebuY,1827
81
+ secondread/report/__init__.py,sha256=qmpB7yHsqq3ElsDbbmAQ0TQSu3s3dtjnKFSpQi5ky9w,1039
82
+ secondread/report/email.py,sha256=PFs0wnYFOKPzAOkue6i3gPzIik4iEvBi2_yiJKj-ujU,1391
83
+ secondread/report/html.py,sha256=8_jDt8b5BeQawa-grr7kzcGGVZcCyNdEAVac6_MvgmQ,1390
84
+ secondread/report/markdown.py,sha256=8VDkRBRsQauaY2LEbfLX405N7kIBLDR6So53KcDPdb8,6770
85
+ secondread/report/text.py,sha256=RKp66smYnDD5-02OPwevFxPXfnr_nmi7xbs9mLvKr2o,5521
86
+ secondread/report/view.py,sha256=OeLDQ_ENJEtW9xkFNA5f2o1I5zDK8frAs6WtMMzQ6Mw,13252
87
+ secondread/report/templates/email.html.j2,sha256=btoKzbpTgQXHHcii5NX3YfbjGKk91ZA6BDwzHWuUit4,6807
88
+ secondread/testing/__init__.py,sha256=BnrCBfoNSE8O3tcaDaGvtQFbe30a-w4SPS7kNkTgy6E,78
89
+ secondread/testing/fakes.py,sha256=aBSW-b95Q1Dl5FsV5vw3g38AE42uFKD-iWuopVzOk_o,14738
90
+ secondread/testing/samples.py,sha256=E49tHPEmOGfOBcbWH2BOVSA-F3UfKpLVtfihGCzX9zU,11362
91
+ second_read-0.1.1.dist-info/METADATA,sha256=s98olonzRSgLTT9xFNcoI12j8T5VR0J4I2xGWpkB3NY,9397
92
+ second_read-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
93
+ second_read-0.1.1.dist-info/entry_points.txt,sha256=zT967noGnSdcioRQKz5hn65E_cYH1CK9Iky9L_hA2Sg,144
94
+ second_read-0.1.1.dist-info/licenses/LICENSE,sha256=OkeCCajJ31d8eQ3UY-3mLFaby5_uy1gXV0yVqFOkaZQ,1072
95
+ second_read-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ second-read = secondread.mcp_local.main:main
3
+ secondread = secondread.cli:main
4
+ secondread-mcp = secondread.mcp_local.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shubham Banthia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
secondread/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,10 @@
1
+ """Copy editor agent.
2
+
3
+ Line-level grammar, clarity and concision suggestions, each a
4
+ ``Severity.STYLE`` finding with a span into the document and a suggestion.
5
+ """
6
+
7
+ from secondread.agents.copyedit.agent import CopyEditAgent, EditOutcome
8
+ from secondread.agents.copyedit.prompts import TAG_EDIT, CopyEditOutput, ProposedEdit
9
+
10
+ __all__ = ["TAG_EDIT", "CopyEditAgent", "CopyEditOutput", "EditOutcome", "ProposedEdit"]
@@ -0,0 +1,261 @@
1
+ """The copy editor agent.
2
+
3
+ Asks a cheap-tier model for line-level edits (verbatim quote plus
4
+ replacement), locates each quote in the original text and returns one
5
+ ``Severity.STYLE`` finding per located edit. Every finding has a span that is
6
+ valid for the document, and ``document.text[span.start:span.end]`` is the
7
+ quoted passage (matched exactly, or modulo whitespace, quote-mark, dash and
8
+ case normalization). Edits that cannot be located, are no-ops, overlap an
9
+ earlier edit or are too large to be line edits are dropped and counted in
10
+ ``AgentResult.notes``. The agent never mutates the ledger.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from dataclasses import dataclass, field
17
+
18
+ from pydantic import ValidationError
19
+
20
+ from secondread.agents.copyedit.prompts import (
21
+ TAG_EDIT,
22
+ CopyEditOutput,
23
+ ProposedEdit,
24
+ build_messages,
25
+ )
26
+ from secondread.agents.copyedit.text import Chunk, TextLocator, split_into_chunks
27
+ from secondread.contracts.errors import BudgetExceeded, ProviderError
28
+ from secondread.contracts.interfaces import LLMResponse, ModelTier, ReviewContext
29
+ from secondread.contracts.models import (
30
+ AgentName,
31
+ AgentResult,
32
+ Finding,
33
+ ProgressEvent,
34
+ ProgressStep,
35
+ Severity,
36
+ Span,
37
+ Usage,
38
+ new_id,
39
+ )
40
+
41
+ log = logging.getLogger(__name__)
42
+
43
+ DEFAULT_MAX_CHUNK_CHARS = 8_000
44
+ """Characters of document per copy-edit call (~2k tokens)."""
45
+
46
+ DEFAULT_MAX_EDITS = 80
47
+ """Cap on edits kept per document (in document order)."""
48
+
49
+ MAX_QUOTE_CHARS = 400
50
+ """Longer quotes are rewrites, not line edits, and are dropped."""
51
+
52
+ _PROMPT_OVERHEAD_TOKENS = 700
53
+ _OUTPUT_ALLOWANCE_TOKENS = 1_500
54
+
55
+
56
+ def estimate_tokens(text: str, style_guide: str | None = None) -> int:
57
+ """Rough token estimate for one copy-edit call over ``text``."""
58
+ guide = len(style_guide or "")
59
+ return (len(text) + guide) // 4 + _PROMPT_OVERHEAD_TOKENS + _OUTPUT_ALLOWANCE_TOKENS
60
+
61
+
62
+ @dataclass
63
+ class EditOutcome:
64
+ """Result of ``edit``: findings plus drop counts for notes and tests."""
65
+
66
+ findings: list[Finding] = field(default_factory=list)
67
+ unlocated: int = 0
68
+ no_ops: int = 0
69
+ oversized: int = 0
70
+ overlapping: int = 0
71
+ truncated: int = 0
72
+ usage: Usage = field(default_factory=Usage)
73
+ error: str | None = None
74
+
75
+ def notes(self) -> list[str]:
76
+ """Human-readable notes (counts only, never document text)."""
77
+ notes: list[str] = []
78
+ if self.unlocated:
79
+ notes.append(
80
+ f"{self.unlocated} copy edit(s) quoted text not found in the document "
81
+ "and were dropped"
82
+ )
83
+ if self.no_ops:
84
+ notes.append(f"{self.no_ops} copy edit(s) that changed nothing were dropped")
85
+ if self.oversized:
86
+ notes.append(f"{self.oversized} copy edit(s) too large for a line edit were dropped")
87
+ if self.overlapping:
88
+ notes.append(f"{self.overlapping} overlapping copy edit(s) were dropped")
89
+ if self.truncated:
90
+ notes.append(f"{self.truncated} copy edit(s) omitted over the cap")
91
+ return notes
92
+
93
+
94
+ class CopyEditAgent:
95
+ """Line-level grammar, clarity and concision suggestions with spans.
96
+
97
+ Implements the ``Agent`` protocol with ``name == AgentName.COPYEDIT``.
98
+ Uses ``ctx.style_guide`` when present. Long documents are processed in
99
+ chunks of ``max_chunk_chars``. Never raises for provider or budget
100
+ failures: returns the edits found so far with ``AgentResult.error`` set.
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ *,
106
+ max_chunk_chars: int = DEFAULT_MAX_CHUNK_CHARS,
107
+ max_edits: int = DEFAULT_MAX_EDITS,
108
+ model_tier: ModelTier = ModelTier.CHEAP,
109
+ ) -> None:
110
+ if max_chunk_chars <= 0 or max_edits <= 0:
111
+ raise ValueError("max_chunk_chars and max_edits must be positive")
112
+ self.max_chunk_chars = max_chunk_chars
113
+ self.max_edits = max_edits
114
+ self.model_tier = model_tier
115
+
116
+ @property
117
+ def name(self) -> str:
118
+ """``AgentName.COPYEDIT``."""
119
+ return AgentName.COPYEDIT
120
+
121
+ async def run(self, ctx: ReviewContext) -> AgentResult:
122
+ """Copy-edit the document and return ``STYLE`` findings."""
123
+ self._emit(ctx, "Copy editing", 0.0)
124
+ outcome = await self.edit(ctx)
125
+ message = f"Copy editing done: {len(outcome.findings)} suggestion(s)"
126
+ if outcome.error:
127
+ message += " (stopped early)"
128
+ self._emit(ctx, message, 100.0)
129
+ log.info(
130
+ "copyedit: %d edits, %d unlocated, error=%s",
131
+ len(outcome.findings),
132
+ outcome.unlocated,
133
+ bool(outcome.error),
134
+ )
135
+ return AgentResult(
136
+ agent=self.name,
137
+ findings=outcome.findings,
138
+ usage=outcome.usage,
139
+ error=outcome.error,
140
+ notes=outcome.notes(),
141
+ )
142
+
143
+ async def edit(self, ctx: ReviewContext) -> EditOutcome:
144
+ """Run copy editing over every chunk and post-process the edits."""
145
+ doc = ctx.document
146
+ outcome = EditOutcome()
147
+ chunks = [c for c in split_into_chunks(doc.text, self.max_chunk_chars) if c.text.strip()]
148
+ taken: list[Span] = []
149
+ for i, chunk in enumerate(chunks, start=1):
150
+ if len(chunks) > 1:
151
+ self._emit(
152
+ ctx,
153
+ f"Copy editing part {i} of {len(chunks)}",
154
+ 100.0 * (i - 1) / len(chunks),
155
+ )
156
+ try:
157
+ response = await self._call(ctx, chunk, i, len(chunks))
158
+ except BudgetExceeded as exc:
159
+ outcome.error = f"budget exhausted: {exc}"
160
+ break
161
+ except ProviderError as exc:
162
+ outcome.error = f"copy edit failed: {exc}"
163
+ break
164
+ outcome.usage = outcome.usage + response.usage
165
+ parsed = _parse(response)
166
+ if parsed is None:
167
+ outcome.error = outcome.error or "copy edit returned malformed output"
168
+ continue
169
+ locator = TextLocator(chunk.text)
170
+ for item in parsed.edits:
171
+ finding = self._to_finding(ctx, item, chunk, locator, taken, outcome)
172
+ if finding is not None:
173
+ outcome.findings.append(finding)
174
+
175
+ outcome.findings.sort(key=lambda f: f.span.start if f.span else 0)
176
+ if len(outcome.findings) > self.max_edits:
177
+ outcome.truncated = len(outcome.findings) - self.max_edits
178
+ outcome.findings = outcome.findings[: self.max_edits]
179
+ for f in outcome.findings: # invariant: every finding has a valid span
180
+ if f.span is None or not f.span.is_valid_for(doc.text): # pragma: no cover
181
+ raise AssertionError("copy-edit finding without a valid span")
182
+ return outcome
183
+
184
+ # -- steps -------------------------------------------------------------
185
+
186
+ async def _call(self, ctx: ReviewContext, chunk: Chunk, part: int, parts: int) -> LLMResponse:
187
+ ctx.meter.require_tokens(estimate_tokens(chunk.text, ctx.style_guide))
188
+ response = await ctx.llm.complete(
189
+ build_messages(
190
+ chunk.text,
191
+ part=part,
192
+ parts=parts,
193
+ style_guide=ctx.style_guide,
194
+ focus=ctx.focus,
195
+ ),
196
+ schema=CopyEditOutput,
197
+ model_tier=self.model_tier,
198
+ tag=TAG_EDIT,
199
+ temperature=0.0,
200
+ )
201
+ ctx.meter.charge(response.usage)
202
+ return response
203
+
204
+ def _to_finding(
205
+ self,
206
+ ctx: ReviewContext,
207
+ item: ProposedEdit,
208
+ chunk: Chunk,
209
+ locator: TextLocator,
210
+ taken: list[Span],
211
+ outcome: EditOutcome,
212
+ ) -> Finding | None:
213
+ quote = item.quote.strip()
214
+ if len(quote) > MAX_QUOTE_CHARS or len(item.suggestion) > 2 * MAX_QUOTE_CHARS:
215
+ outcome.oversized += 1
216
+ return None
217
+ # Trimming is not allowed: the suggestion replaces exactly the quote,
218
+ # so a span that silently drops punctuation would corrupt the edit.
219
+ located = locator.locate(quote, allow_trim=False)
220
+ if located is None:
221
+ outcome.unlocated += 1
222
+ return None
223
+ spans = [Span(start=s.start + chunk.start, end=s.end + chunk.start) for s in located.spans]
224
+ # A repeated quote maps to its first occurrence not already edited.
225
+ span = next((s for s in spans if not any(s.overlaps(t) for t in taken)), None)
226
+ if span is None:
227
+ outcome.overlapping += 1
228
+ return None
229
+ original = span.text_of(ctx.document.text)
230
+ suggestion = item.suggestion.strip()
231
+ if suggestion == original.strip():
232
+ outcome.no_ops += 1
233
+ return None
234
+ taken.append(span)
235
+ reason = item.reason.strip() or "Suggested wording change."
236
+ return Finding(
237
+ id=new_id("find"),
238
+ agent=self.name,
239
+ severity=Severity.STYLE,
240
+ span=span,
241
+ message=f"{item.category.capitalize()}: {reason}",
242
+ suggestion=suggestion,
243
+ claim_ids=[e.claim.id for e in ctx.ledger.claims_overlapping(span)],
244
+ )
245
+
246
+ def _emit(self, ctx: ReviewContext, message: str, percent: float) -> None:
247
+ try:
248
+ ctx.emit_progress(
249
+ ProgressEvent(step=ProgressStep.REVIEWING, message=message, percent=percent)
250
+ )
251
+ except Exception: # progress must never break the review
252
+ log.warning("copyedit: progress callback raised", exc_info=True)
253
+
254
+
255
+ def _parse(response: LLMResponse) -> CopyEditOutput | None:
256
+ if isinstance(response.parsed, CopyEditOutput):
257
+ return response.parsed
258
+ try:
259
+ return CopyEditOutput.model_validate_json(response.text)
260
+ except ValidationError:
261
+ return None