pyrepowiki-cli 0.1.0__tar.gz → 0.2.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.
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/PKG-INFO +17 -6
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/README.md +16 -5
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/README.zh-CN.md +11 -5
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/pyproject.toml +1 -1
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/zread/cli.py +124 -16
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/zread/client.py +112 -23
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_zread_cli.py +138 -2
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_zread_client.py +95 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/uv.lock +34 -34
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/.gitignore +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/__init__.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/cli.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/__init__.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/codewiki/__init__.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/codewiki/boq.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/codewiki/cli.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/codewiki/client.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/codewiki/wiki.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/deepwiki/__init__.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/deepwiki/cli.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/deepwiki/client.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/deepwiki/codemap.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/deepwiki/devin.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/zread/__init__.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/zread/flight.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/__init__.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/async_.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/export.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/model.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/output.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/repo.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/save.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/test.md +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/fixtures/codewiki/egixfe_response.txt +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/fixtures/codewiki/vsx6ub_response.txt +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_boq.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_cli.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_client.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_codemap.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_codewiki_cli.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_codewiki_client.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_codewiki_wiki.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_devin.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_docs.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_model.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_output.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_repo.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_save.py +0 -0
- {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_zread_flight.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyrepowiki-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: CLI tool for querying DeepWiki/Google code wiki/Zread wiki documentation
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -679,7 +679,7 @@ read commands need **no auth**. The commands `ask` and `submit` require a token
|
|
|
679
679
|
```bash
|
|
680
680
|
repowiki-cli zread structure REPO [--lang zh|en] [--json]
|
|
681
681
|
repowiki-cli zread contents REPO [SLUG] [--file PATH] [--start N] [--end M] [--lang zh|en] [--rich] [--json]
|
|
682
|
-
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH]
|
|
682
|
+
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
|
|
683
683
|
repowiki-cli zread find QUERY [--limit N] [--lang zh|en] [--json]
|
|
684
684
|
repowiki-cli zread stat REPO [--lang zh|en] [--human] [--stale] [--json]
|
|
685
685
|
repowiki-cli zread search REPO QUERY [--lang zh|en] [--json]
|
|
@@ -730,12 +730,15 @@ repowiki-cli zread contents https://github.com/o/r/blob/main/src/a.py#L10-L20
|
|
|
730
730
|
### `zread ask`
|
|
731
731
|
|
|
732
732
|
```bash
|
|
733
|
-
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH]
|
|
733
|
+
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
|
|
734
734
|
```
|
|
735
735
|
|
|
736
736
|
With `QUESTION`, `ask` answers once and exits. Without it, `ask` starts an
|
|
737
737
|
interactive REPL — type one question per line and `/exit` (or `/quit`/`/q`) to
|
|
738
|
-
quit.
|
|
738
|
+
quit. In the REPL each turn is **threaded**: the same `talk_id` is reused and the
|
|
739
|
+
previous answer's message id is sent as `parent_message_id`, so follow-up
|
|
740
|
+
questions carry conversational memory. Type `/new` (or `/reset`) to start a fresh
|
|
741
|
+
thread.
|
|
739
742
|
|
|
740
743
|
Unlike the other commands, `ask` requires an auth token (a JWT). Log in to
|
|
741
744
|
zread.ai, open the browser DevTools console, and run
|
|
@@ -746,9 +749,17 @@ the token. Set it as the `ZREAD_TOKEN` environment variable.
|
|
|
746
749
|
variable). This maps to the web UI's `CGX_CHAT_MODEL` (e.g. `glm-5.1`,
|
|
747
750
|
`claude-sonnet-4.6`).
|
|
748
751
|
- `--lang zh|en` — language.
|
|
749
|
-
- `--rich` — render the answer's Markdown with `rich`.
|
|
750
|
-
|
|
752
|
+
- `--rich` — render the answer's Markdown with `rich`. Has no effect with
|
|
753
|
+
`--stream` (the answer streams as plain text).
|
|
754
|
+
- `--json` — emit a JSON envelope. Ignored in interactive mode. Has no effect
|
|
755
|
+
with `--stream`.
|
|
751
756
|
- `--save PATH` — save the answer to a Markdown file (see *Saving*).
|
|
757
|
+
- `--stream` — stream the answer's `answer` SSE chunks to stdout as they arrive
|
|
758
|
+
(plain text), instead of buffering until the full answer is ready.
|
|
759
|
+
- `--show-reasoning` — also show the model's reasoning trace (the
|
|
760
|
+
`reasoning_content` SSE field, streamed before the answer). In `--stream` mode
|
|
761
|
+
it prints a `reasoning:` block before the `answer:` block; otherwise it prints
|
|
762
|
+
the trace dimmed above the answer. Has no effect with `--json`.
|
|
752
763
|
|
|
753
764
|
### `zread find`
|
|
754
765
|
|
|
@@ -666,7 +666,7 @@ read commands need **no auth**. The commands `ask` and `submit` require a token
|
|
|
666
666
|
```bash
|
|
667
667
|
repowiki-cli zread structure REPO [--lang zh|en] [--json]
|
|
668
668
|
repowiki-cli zread contents REPO [SLUG] [--file PATH] [--start N] [--end M] [--lang zh|en] [--rich] [--json]
|
|
669
|
-
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH]
|
|
669
|
+
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
|
|
670
670
|
repowiki-cli zread find QUERY [--limit N] [--lang zh|en] [--json]
|
|
671
671
|
repowiki-cli zread stat REPO [--lang zh|en] [--human] [--stale] [--json]
|
|
672
672
|
repowiki-cli zread search REPO QUERY [--lang zh|en] [--json]
|
|
@@ -717,12 +717,15 @@ repowiki-cli zread contents https://github.com/o/r/blob/main/src/a.py#L10-L20
|
|
|
717
717
|
### `zread ask`
|
|
718
718
|
|
|
719
719
|
```bash
|
|
720
|
-
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH]
|
|
720
|
+
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
|
|
721
721
|
```
|
|
722
722
|
|
|
723
723
|
With `QUESTION`, `ask` answers once and exits. Without it, `ask` starts an
|
|
724
724
|
interactive REPL — type one question per line and `/exit` (or `/quit`/`/q`) to
|
|
725
|
-
quit.
|
|
725
|
+
quit. In the REPL each turn is **threaded**: the same `talk_id` is reused and the
|
|
726
|
+
previous answer's message id is sent as `parent_message_id`, so follow-up
|
|
727
|
+
questions carry conversational memory. Type `/new` (or `/reset`) to start a fresh
|
|
728
|
+
thread.
|
|
726
729
|
|
|
727
730
|
Unlike the other commands, `ask` requires an auth token (a JWT). Log in to
|
|
728
731
|
zread.ai, open the browser DevTools console, and run
|
|
@@ -733,9 +736,17 @@ the token. Set it as the `ZREAD_TOKEN` environment variable.
|
|
|
733
736
|
variable). This maps to the web UI's `CGX_CHAT_MODEL` (e.g. `glm-5.1`,
|
|
734
737
|
`claude-sonnet-4.6`).
|
|
735
738
|
- `--lang zh|en` — language.
|
|
736
|
-
- `--rich` — render the answer's Markdown with `rich`.
|
|
737
|
-
|
|
739
|
+
- `--rich` — render the answer's Markdown with `rich`. Has no effect with
|
|
740
|
+
`--stream` (the answer streams as plain text).
|
|
741
|
+
- `--json` — emit a JSON envelope. Ignored in interactive mode. Has no effect
|
|
742
|
+
with `--stream`.
|
|
738
743
|
- `--save PATH` — save the answer to a Markdown file (see *Saving*).
|
|
744
|
+
- `--stream` — stream the answer's `answer` SSE chunks to stdout as they arrive
|
|
745
|
+
(plain text), instead of buffering until the full answer is ready.
|
|
746
|
+
- `--show-reasoning` — also show the model's reasoning trace (the
|
|
747
|
+
`reasoning_content` SSE field, streamed before the answer). In `--stream` mode
|
|
748
|
+
it prints a `reasoning:` block before the `answer:` block; otherwise it prints
|
|
749
|
+
the trace dimmed above the answer. Has no effect with `--json`.
|
|
739
750
|
|
|
740
751
|
### `zread find`
|
|
741
752
|
|
|
@@ -621,7 +621,7 @@ repowiki-cli codewiki cp REPO [OUTPUT_DIR]
|
|
|
621
621
|
```bash
|
|
622
622
|
repowiki-cli zread structure REPO [--lang zh|en] [--json]
|
|
623
623
|
repowiki-cli zread contents REPO [SLUG] [--file PATH] [--start N] [--end M] [--lang zh|en] [--rich] [--json]
|
|
624
|
-
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH]
|
|
624
|
+
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
|
|
625
625
|
repowiki-cli zread find QUERY [--limit N] [--lang zh|en] [--json]
|
|
626
626
|
repowiki-cli zread stat REPO [--lang zh|en] [--human] [--stale] [--json]
|
|
627
627
|
repowiki-cli zread search REPO QUERY [--lang zh|en] [--json]
|
|
@@ -668,11 +668,13 @@ repowiki-cli zread contents https://github.com/o/r/blob/main/src/a.py#L10-L20
|
|
|
668
668
|
### `zread ask`
|
|
669
669
|
|
|
670
670
|
```bash
|
|
671
|
-
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH]
|
|
671
|
+
repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
|
|
672
672
|
```
|
|
673
673
|
|
|
674
674
|
带 `QUESTION` 则单次回答后退出;不带则进入交互式 REPL——每行一个问题,输入
|
|
675
|
-
`/exit`(或 `/quit`/`/q`)退出。
|
|
675
|
+
`/exit`(或 `/quit`/`/q`)退出。REPL 中每轮都是**多轮续接**的:复用同一个
|
|
676
|
+
`talk_id`,并把上一轮答案的 message id 作为 `parent_message_id` 发送,因此追问
|
|
677
|
+
能携带上下文记忆。输入 `/new`(或 `/reset`)可开启一个新线程。
|
|
676
678
|
|
|
677
679
|
与其他命令不同,`ask` 需要鉴权 token(一个 JWT)。登录 zread.ai 后,在浏览器
|
|
678
680
|
DevTools 控制台执行
|
|
@@ -682,9 +684,13 @@ DevTools 控制台执行
|
|
|
682
684
|
- `--model MODEL` — 模型(默认 `glm-5.1`,或环境变量 `ZREAD_MODEL`)。对应网页端的
|
|
683
685
|
`CGX_CHAT_MODEL`(如 `glm-5.1`、`claude-sonnet-4.6`)。
|
|
684
686
|
- `--lang zh|en` — 语言。
|
|
685
|
-
- `--rich` — 用 `rich` 渲染答案的 Markdown
|
|
686
|
-
- `--json` — 输出 JSON
|
|
687
|
+
- `--rich` — 用 `rich` 渲染答案的 Markdown。与 `--stream` 一起使用时无效(流式输出为纯文本)。
|
|
688
|
+
- `--json` — 输出 JSON 信封;交互模式下忽略。与 `--stream` 一起使用时无效。
|
|
687
689
|
- `--save PATH` — 把答案保存为 Markdown 文件(见「保存」)。
|
|
690
|
+
- `--stream` — 通过 SSE 把 `answer` 事件的分片逐字写到 stdout,流式输出纯文本答案。
|
|
691
|
+
- `--show-reasoning` — 同时展示模型的思考过程(SSE 的 `reasoning_content` 字段,
|
|
692
|
+
在回答之前流式下发)。`--stream` 模式下会先输出 `reasoning:` 块再输出
|
|
693
|
+
`answer:` 块;否则以暗色打印在答案上方。与 `--json` 一起使用时无效。
|
|
688
694
|
|
|
689
695
|
### `zread find`
|
|
690
696
|
|
|
@@ -137,6 +137,49 @@ def _repl_prompt() -> str:
|
|
|
137
137
|
return typer.style(">> ", fg=typer.colors.CYAN, bold=True)
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def _stream_chunk(text: str) -> None:
|
|
141
|
+
sys.stdout.write(text)
|
|
142
|
+
sys.stdout.flush()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _streamers(show_reasoning: bool):
|
|
146
|
+
"""Return ``(on_reasoning, on_chunk)`` callbacks for ``--stream``.
|
|
147
|
+
|
|
148
|
+
Without ``--show-reasoning`` this streams only the answer (reasoning is
|
|
149
|
+
dropped). With it, the reasoning trace streams first under a ``reasoning:``
|
|
150
|
+
label, then the answer under an ``answer:`` label.
|
|
151
|
+
"""
|
|
152
|
+
if not show_reasoning:
|
|
153
|
+
return None, _stream_chunk
|
|
154
|
+
state = {"reasoning": False, "answer": False}
|
|
155
|
+
|
|
156
|
+
def on_reasoning(text: str) -> None:
|
|
157
|
+
if not state["reasoning"]:
|
|
158
|
+
sys.stdout.write("\nreasoning:\n")
|
|
159
|
+
state["reasoning"] = True
|
|
160
|
+
sys.stdout.write(text)
|
|
161
|
+
sys.stdout.flush()
|
|
162
|
+
|
|
163
|
+
def on_chunk(text: str) -> None:
|
|
164
|
+
if not state["answer"]:
|
|
165
|
+
if state["reasoning"]:
|
|
166
|
+
sys.stdout.write("\n\nanswer:\n")
|
|
167
|
+
state["answer"] = True
|
|
168
|
+
sys.stdout.write(text)
|
|
169
|
+
sys.stdout.flush()
|
|
170
|
+
|
|
171
|
+
return on_reasoning, on_chunk
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _print_reasoning(reasoning: str) -> None:
|
|
175
|
+
"""Print a captured reasoning trace (dimmed) above the answer."""
|
|
176
|
+
if not reasoning.strip():
|
|
177
|
+
return
|
|
178
|
+
typer.secho("reasoning:", fg=typer.colors.YELLOW, dim=True)
|
|
179
|
+
typer.secho(reasoning.strip(), fg=typer.colors.YELLOW, dim=True)
|
|
180
|
+
typer.echo()
|
|
181
|
+
|
|
182
|
+
|
|
140
183
|
def _print_error(exc: Exception) -> None:
|
|
141
184
|
if isinstance(exc, ZreadError):
|
|
142
185
|
message = str(exc)
|
|
@@ -155,10 +198,16 @@ def _append_save(path: Optional[str], repo: str, question: str, answer: str) ->
|
|
|
155
198
|
|
|
156
199
|
|
|
157
200
|
async def _repl(
|
|
158
|
-
resolved: str, rich: bool, save_path: Optional[str], model: Optional[str], lang: Optional[str]
|
|
201
|
+
resolved: str, rich: bool, save_path: Optional[str], model: Optional[str], lang: Optional[str],
|
|
202
|
+
stream: bool = False, show_reasoning: bool = False,
|
|
159
203
|
) -> None:
|
|
160
204
|
prompt = _repl_prompt()
|
|
161
205
|
client = _make_client(lang, model)
|
|
206
|
+
try:
|
|
207
|
+
talk = await client.start_talk(resolved)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
_print_error(exc)
|
|
210
|
+
return
|
|
162
211
|
while True:
|
|
163
212
|
try:
|
|
164
213
|
line = input(prompt)
|
|
@@ -170,19 +219,37 @@ async def _repl(
|
|
|
170
219
|
continue
|
|
171
220
|
if q in ("/exit", "/quit", "/q"):
|
|
172
221
|
break
|
|
222
|
+
if q in ("/new", "/reset"):
|
|
223
|
+
try:
|
|
224
|
+
talk = await client.start_talk(resolved)
|
|
225
|
+
typer.echo("Started a new thread.")
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
_print_error(exc)
|
|
228
|
+
continue
|
|
173
229
|
try:
|
|
174
|
-
|
|
175
|
-
|
|
230
|
+
if stream:
|
|
231
|
+
on_reasoning, on_chunk = _streamers(show_reasoning)
|
|
232
|
+
answer = await talk.ask(q, on_chunk=on_chunk, on_reasoning=on_reasoning)
|
|
233
|
+
else:
|
|
234
|
+
reasoning_parts: list[str] = []
|
|
235
|
+
with status("Thinking..."):
|
|
236
|
+
answer = await talk.ask(
|
|
237
|
+
q, on_reasoning=reasoning_parts.append if show_reasoning else None
|
|
238
|
+
)
|
|
176
239
|
except Exception as exc:
|
|
177
240
|
_print_error(exc)
|
|
178
241
|
continue
|
|
179
242
|
_append_save(save_path, resolved, q, answer)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
render_rich(answer.strip())
|
|
243
|
+
if stream:
|
|
244
|
+
typer.echo()
|
|
183
245
|
else:
|
|
184
|
-
|
|
185
|
-
|
|
246
|
+
_print_reasoning("".join(reasoning_parts))
|
|
247
|
+
typer.echo()
|
|
248
|
+
if rich:
|
|
249
|
+
render_rich(answer.strip())
|
|
250
|
+
else:
|
|
251
|
+
typer.echo(answer.strip())
|
|
252
|
+
typer.echo()
|
|
186
253
|
|
|
187
254
|
|
|
188
255
|
def _render_structure(pages: list[Page]) -> str:
|
|
@@ -416,24 +483,65 @@ def ask(
|
|
|
416
483
|
rich: bool = typer.Option(False, "--rich", help="Render Markdown with rich"),
|
|
417
484
|
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
418
485
|
save: Optional[str] = typer.Option(None, "--save", help="Save the answer to a Markdown file."),
|
|
486
|
+
stream: bool = typer.Option(False, "--stream", help="Stream answer chunks as they arrive"),
|
|
487
|
+
show_reasoning: bool = typer.Option(
|
|
488
|
+
False, "--show-reasoning", help="Also show the model's reasoning trace",
|
|
489
|
+
),
|
|
419
490
|
) -> None:
|
|
420
491
|
"""Ask a question about a repository (single-shot or interactive)."""
|
|
421
492
|
resolved = _resolve_repo(repo, json)
|
|
422
493
|
if question is not None:
|
|
423
494
|
if not question.strip():
|
|
424
495
|
_fail("Question must not be empty.", "invalid_input", json)
|
|
496
|
+
if stream and json:
|
|
497
|
+
typer.secho(
|
|
498
|
+
"Warning: --stream has no effect with --json.",
|
|
499
|
+
fg=typer.colors.YELLOW, err=True,
|
|
500
|
+
)
|
|
501
|
+
if stream and rich:
|
|
502
|
+
typer.secho(
|
|
503
|
+
"Warning: --rich has no effect with --stream; the answer streams as plain text.",
|
|
504
|
+
fg=typer.colors.YELLOW, err=True,
|
|
505
|
+
)
|
|
506
|
+
if show_reasoning and json:
|
|
507
|
+
typer.secho(
|
|
508
|
+
"Warning: --show-reasoning has no effect with --json.",
|
|
509
|
+
fg=typer.colors.YELLOW, err=True,
|
|
510
|
+
)
|
|
425
511
|
if (mock := _mock_text()) is not None:
|
|
426
512
|
answer = Answer(body=mock)
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
513
|
+
if json:
|
|
514
|
+
typer.echo(format_json(resolved, "ask", question=question, answer=answer.body))
|
|
515
|
+
else:
|
|
516
|
+
_emit(resolved, "ask", format_answer(answer), rich, False)
|
|
517
|
+
_append_save(save, resolved, question, answer.body)
|
|
518
|
+
return
|
|
519
|
+
client = _make_client(lang, model)
|
|
520
|
+
streamed = False
|
|
521
|
+
reasoning_parts: list[str] = []
|
|
522
|
+
try:
|
|
523
|
+
if stream and not json:
|
|
524
|
+
on_reasoning, on_chunk = _streamers(show_reasoning)
|
|
525
|
+
answer = Answer(body=run_async(
|
|
526
|
+
client.ask(resolved, question, on_chunk=on_chunk, on_reasoning=on_reasoning)
|
|
527
|
+
))
|
|
528
|
+
streamed = True
|
|
529
|
+
else:
|
|
430
530
|
with status("Thinking..."):
|
|
431
|
-
answer = Answer(body=run_async(
|
|
432
|
-
|
|
433
|
-
|
|
531
|
+
answer = Answer(body=run_async(
|
|
532
|
+
client.ask(
|
|
533
|
+
resolved, question,
|
|
534
|
+
on_reasoning=reasoning_parts.append if show_reasoning else None,
|
|
535
|
+
)
|
|
536
|
+
))
|
|
537
|
+
except Exception as exc:
|
|
538
|
+
_handle_exception(exc, json)
|
|
434
539
|
if json:
|
|
435
540
|
typer.echo(format_json(resolved, "ask", question=question, answer=answer.body))
|
|
541
|
+
elif streamed:
|
|
542
|
+
typer.echo()
|
|
436
543
|
else:
|
|
544
|
+
_print_reasoning("".join(reasoning_parts))
|
|
437
545
|
_emit(resolved, "ask", format_answer(answer), rich, False)
|
|
438
546
|
_append_save(save, resolved, question, answer.body)
|
|
439
547
|
return
|
|
@@ -441,10 +549,10 @@ def ask(
|
|
|
441
549
|
typer.secho("Warning: --json has no effect in interactive mode.", fg=typer.colors.YELLOW, err=True)
|
|
442
550
|
typer.echo(format_header("Zread", resolved, "ask"))
|
|
443
551
|
typer.echo()
|
|
444
|
-
typer.echo("Ask a question, or /exit to quit.")
|
|
552
|
+
typer.echo("Ask a question, or /exit to quit. (/new starts a new thread)")
|
|
445
553
|
typer.echo()
|
|
446
554
|
try:
|
|
447
|
-
run_async(_repl(resolved, rich, save, model, lang))
|
|
555
|
+
run_async(_repl(resolved, rich, save, model, lang, stream, show_reasoning))
|
|
448
556
|
except Exception as exc:
|
|
449
557
|
_handle_exception(exc, False)
|
|
450
558
|
|
|
@@ -84,31 +84,76 @@ def _unwrap(resp: httpx.Response) -> object:
|
|
|
84
84
|
return result.get("data")
|
|
85
85
|
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
chunks
|
|
91
|
-
|
|
87
|
+
class _SSEParser:
|
|
88
|
+
"""Incrementally parse the talk SSE stream.
|
|
89
|
+
|
|
90
|
+
``answer`` events carry incremental chunks — ``data.text`` is the answer and
|
|
91
|
+
``data.reasoning_content`` is the model's thinking trace (streamed first, then
|
|
92
|
+
empty during the answer phase). ``round_finish`` carries the complete text,
|
|
93
|
+
``finish`` ends the stream. ``on_chunk`` / ``on_reasoning``, when given, are
|
|
94
|
+
invoked with each ``answer`` chunk as it arrives.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, on_chunk=None, on_reasoning=None) -> None:
|
|
98
|
+
self._on_chunk = on_chunk
|
|
99
|
+
self._on_reasoning = on_reasoning
|
|
100
|
+
self._event: str | None = None
|
|
101
|
+
self._finished: list[str] = []
|
|
102
|
+
self._chunks: list[str] = []
|
|
103
|
+
self._reasoning: list[str] = []
|
|
104
|
+
self._message_id = ""
|
|
105
|
+
|
|
106
|
+
def feed(self, line: str) -> bool:
|
|
107
|
+
"""Consume one raw SSE line; return True when the stream is done."""
|
|
92
108
|
line = line.strip()
|
|
93
109
|
if not line:
|
|
94
|
-
|
|
110
|
+
return False
|
|
95
111
|
if line.startswith("event:"):
|
|
96
|
-
|
|
97
|
-
if
|
|
98
|
-
|
|
99
|
-
if
|
|
112
|
+
self._event = line[6:].strip()
|
|
113
|
+
if self._event == "finish":
|
|
114
|
+
return True
|
|
115
|
+
if self._event == "error":
|
|
100
116
|
raise ZreadError("talk stream returned an error event")
|
|
101
117
|
elif line.startswith("data:"):
|
|
102
118
|
try:
|
|
103
119
|
data = json.loads(line[5:].strip())
|
|
104
120
|
except json.JSONDecodeError:
|
|
105
|
-
|
|
106
|
-
if
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
return False
|
|
122
|
+
if data.get("id"):
|
|
123
|
+
self._message_id = data["id"]
|
|
124
|
+
if self._event == "round_finish":
|
|
125
|
+
self._finished.append(data.get("text", ""))
|
|
126
|
+
elif self._event == "answer":
|
|
127
|
+
reasoning = data.get("reasoning_content", "")
|
|
128
|
+
if reasoning:
|
|
129
|
+
self._reasoning.append(reasoning)
|
|
130
|
+
if self._on_reasoning is not None:
|
|
131
|
+
self._on_reasoning(reasoning)
|
|
132
|
+
text = data.get("text", "")
|
|
133
|
+
self._chunks.append(text)
|
|
134
|
+
if self._on_chunk is not None and text:
|
|
135
|
+
self._on_chunk(text)
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def text(self) -> str:
|
|
140
|
+
return ("\n".join(self._finished) or "".join(self._chunks)).strip()
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def reasoning(self) -> str:
|
|
144
|
+
return "".join(self._reasoning).strip()
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def message_id(self) -> str:
|
|
148
|
+
return self._message_id
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_sse_body(lines: list[str], on_chunk=None, on_reasoning=None) -> str:
|
|
152
|
+
parser = _SSEParser(on_chunk, on_reasoning)
|
|
153
|
+
for line in lines:
|
|
154
|
+
if parser.feed(line):
|
|
155
|
+
break
|
|
156
|
+
return parser.text
|
|
112
157
|
|
|
113
158
|
|
|
114
159
|
class ZreadClient:
|
|
@@ -349,7 +394,18 @@ class ZreadClient:
|
|
|
349
394
|
raise ZreadNotFoundError(f"no wiki_id for {repo}")
|
|
350
395
|
return wiki_id
|
|
351
396
|
|
|
352
|
-
async def ask(self, repo: str, question: str) -> str:
|
|
397
|
+
async def ask(self, repo: str, question: str, on_chunk=None, on_reasoning=None) -> str:
|
|
398
|
+
if not self._token:
|
|
399
|
+
raise ZreadError(
|
|
400
|
+
"ZREAD_TOKEN is required for ask. Get it from zread.ai after login: "
|
|
401
|
+
"run JSON.parse(localStorage.getItem('CGX_AUTH_STORAGE')).state.token in "
|
|
402
|
+
"the DevTools console, then export ZREAD_TOKEN."
|
|
403
|
+
)
|
|
404
|
+
talk = await self.start_talk(repo)
|
|
405
|
+
return await talk.ask(question, on_chunk=on_chunk, on_reasoning=on_reasoning)
|
|
406
|
+
|
|
407
|
+
async def start_talk(self, repo: str) -> "ZreadTalk":
|
|
408
|
+
"""Resolve a repo and open a new talk thread (``talk_id``)."""
|
|
353
409
|
if not self._token:
|
|
354
410
|
raise ZreadError(
|
|
355
411
|
"ZREAD_TOKEN is required for ask. Get it from zread.ai after login: "
|
|
@@ -365,7 +421,7 @@ class ZreadClient:
|
|
|
365
421
|
if not pages:
|
|
366
422
|
raise ZreadNotFoundError(f"{repo} has no wiki pages")
|
|
367
423
|
talk_id = await self._create_talk()
|
|
368
|
-
return
|
|
424
|
+
return ZreadTalk(self, repo_id, wiki_id, pages[0].page_id, talk_id)
|
|
369
425
|
|
|
370
426
|
async def _create_talk(self) -> str:
|
|
371
427
|
resp = await self._request(
|
|
@@ -382,7 +438,8 @@ class ZreadClient:
|
|
|
382
438
|
async def _send_message(
|
|
383
439
|
self, talk_id: str, question: str,
|
|
384
440
|
wiki_id: str, page_id: str, repo_id: str,
|
|
385
|
-
|
|
441
|
+
on_chunk=None, on_reasoning=None, parent_message_id: str = "",
|
|
442
|
+
) -> tuple[str, str]:
|
|
386
443
|
url = f"{BASE}/api/v1/talk/{talk_id}/message"
|
|
387
444
|
headers = self._headers({
|
|
388
445
|
"Authorization": f"Bearer {self._token}",
|
|
@@ -392,7 +449,7 @@ class ZreadClient:
|
|
|
392
449
|
if repo_id:
|
|
393
450
|
context["repo"] = {"repo_id": repo_id}
|
|
394
451
|
body = {
|
|
395
|
-
"parent_message_id":
|
|
452
|
+
"parent_message_id": parent_message_id,
|
|
396
453
|
"query": question,
|
|
397
454
|
"context": context,
|
|
398
455
|
"model": self._model,
|
|
@@ -410,5 +467,37 @@ class ZreadClient:
|
|
|
410
467
|
f"Zread returned HTTP {resp.status_code} for {url}"
|
|
411
468
|
+ (f": {detail}" if detail else "")
|
|
412
469
|
)
|
|
413
|
-
|
|
414
|
-
|
|
470
|
+
parser = _SSEParser(on_chunk, on_reasoning)
|
|
471
|
+
async for line in resp.aiter_lines():
|
|
472
|
+
if parser.feed(line):
|
|
473
|
+
break
|
|
474
|
+
return parser.text, parser.message_id
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class ZreadTalk:
|
|
478
|
+
"""A single threaded conversation against one repo.
|
|
479
|
+
|
|
480
|
+
Holds the ``talk_id`` and chains messages via ``parent_message_id`` (the id
|
|
481
|
+
of the previous assistant message, captured from the SSE ``id`` field) so
|
|
482
|
+
follow-up questions carry conversational memory.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
def __init__(
|
|
486
|
+
self, client: "ZreadClient", repo_id: str, wiki_id: str, page_id: str, talk_id: str
|
|
487
|
+
) -> None:
|
|
488
|
+
self._client = client
|
|
489
|
+
self._repo_id = repo_id
|
|
490
|
+
self._wiki_id = wiki_id
|
|
491
|
+
self._page_id = page_id
|
|
492
|
+
self._talk_id = talk_id
|
|
493
|
+
self._parent_message_id = ""
|
|
494
|
+
|
|
495
|
+
async def ask(self, question: str, on_chunk=None, on_reasoning=None) -> str:
|
|
496
|
+
answer, message_id = await self._client._send_message(
|
|
497
|
+
self._talk_id, question, self._wiki_id, self._page_id, self._repo_id,
|
|
498
|
+
on_chunk=on_chunk, on_reasoning=on_reasoning,
|
|
499
|
+
parent_message_id=self._parent_message_id,
|
|
500
|
+
)
|
|
501
|
+
if message_id:
|
|
502
|
+
self._parent_message_id = message_id
|
|
503
|
+
return answer
|
|
@@ -156,12 +156,16 @@ def test_ask_repl(monkeypatch):
|
|
|
156
156
|
inputs = iter(["What is Fiber?", "/exit"])
|
|
157
157
|
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
|
|
158
158
|
|
|
159
|
+
class FakeTalk:
|
|
160
|
+
async def ask(self, question, on_chunk=None, on_reasoning=None):
|
|
161
|
+
return f"answer to {question}"
|
|
162
|
+
|
|
159
163
|
class FakeClient:
|
|
160
164
|
def __init__(self, **kwargs):
|
|
161
165
|
pass
|
|
162
166
|
|
|
163
|
-
async def
|
|
164
|
-
return
|
|
167
|
+
async def start_talk(self, repo):
|
|
168
|
+
return FakeTalk()
|
|
165
169
|
|
|
166
170
|
monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
|
|
167
171
|
result = runner.invoke(zread_app, ["ask", "facebook/react"])
|
|
@@ -169,6 +173,138 @@ def test_ask_repl(monkeypatch):
|
|
|
169
173
|
assert "answer to What is Fiber?" in result.output
|
|
170
174
|
|
|
171
175
|
|
|
176
|
+
def test_ask_repl_reuses_talk(monkeypatch):
|
|
177
|
+
inputs = iter(["q1", "q2", "/exit"])
|
|
178
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
|
|
179
|
+
started = {"n": 0}
|
|
180
|
+
|
|
181
|
+
class FakeTalk:
|
|
182
|
+
async def ask(self, question, on_chunk=None, on_reasoning=None):
|
|
183
|
+
return f"answer to {question}"
|
|
184
|
+
|
|
185
|
+
class FakeClient:
|
|
186
|
+
def __init__(self, **kwargs):
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
async def start_talk(self, repo):
|
|
190
|
+
started["n"] += 1
|
|
191
|
+
return FakeTalk()
|
|
192
|
+
|
|
193
|
+
monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
|
|
194
|
+
result = runner.invoke(zread_app, ["ask", "facebook/react"])
|
|
195
|
+
assert result.exit_code == 0
|
|
196
|
+
assert "answer to q1" in result.output
|
|
197
|
+
assert "answer to q2" in result.output
|
|
198
|
+
assert started["n"] == 1
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_ask_repl_new_thread(monkeypatch):
|
|
202
|
+
inputs = iter(["q1", "/new", "/exit"])
|
|
203
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
|
|
204
|
+
started = {"n": 0}
|
|
205
|
+
|
|
206
|
+
class FakeTalk:
|
|
207
|
+
async def ask(self, question, on_chunk=None, on_reasoning=None):
|
|
208
|
+
return f"answer to {question}"
|
|
209
|
+
|
|
210
|
+
class FakeClient:
|
|
211
|
+
def __init__(self, **kwargs):
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
async def start_talk(self, repo):
|
|
215
|
+
started["n"] += 1
|
|
216
|
+
return FakeTalk()
|
|
217
|
+
|
|
218
|
+
monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
|
|
219
|
+
result = runner.invoke(zread_app, ["ask", "facebook/react"])
|
|
220
|
+
assert result.exit_code == 0
|
|
221
|
+
assert started["n"] == 2
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_ask_stream_emits_chunks(monkeypatch):
|
|
225
|
+
class FakeClient:
|
|
226
|
+
def __init__(self, **kwargs):
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
async def ask(self, repo, question, on_chunk=None, on_reasoning=None):
|
|
230
|
+
if on_chunk is not None:
|
|
231
|
+
on_chunk("hel")
|
|
232
|
+
on_chunk("lo")
|
|
233
|
+
return "hello"
|
|
234
|
+
|
|
235
|
+
monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
|
|
236
|
+
result = runner.invoke(zread_app, ["ask", "owner/example", "q?", "--stream"])
|
|
237
|
+
assert result.exit_code == 0
|
|
238
|
+
assert "hello" in result.output
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_ask_stream_warns_json(monkeypatch):
|
|
242
|
+
class FakeClient:
|
|
243
|
+
def __init__(self, **kwargs):
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
async def ask(self, repo, question, on_chunk=None, on_reasoning=None):
|
|
247
|
+
return "hello"
|
|
248
|
+
|
|
249
|
+
monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
|
|
250
|
+
result = runner.invoke(zread_app, ["ask", "owner/example", "q?", "--stream", "--json"])
|
|
251
|
+
assert result.exit_code == 0
|
|
252
|
+
assert "--stream has no effect with --json" in result.output
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_ask_show_reasoning(monkeypatch):
|
|
256
|
+
class FakeClient:
|
|
257
|
+
def __init__(self, **kwargs):
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
async def ask(self, repo, question, on_chunk=None, on_reasoning=None):
|
|
261
|
+
if on_reasoning is not None:
|
|
262
|
+
on_reasoning("think ")
|
|
263
|
+
on_reasoning("hard")
|
|
264
|
+
return "hello"
|
|
265
|
+
|
|
266
|
+
monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
|
|
267
|
+
result = runner.invoke(zread_app, ["ask", "owner/example", "q?", "--show-reasoning"])
|
|
268
|
+
assert result.exit_code == 0
|
|
269
|
+
assert "think hard" in result.output
|
|
270
|
+
assert "hello" in result.output
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_ask_show_reasoning_warns_json(monkeypatch):
|
|
274
|
+
class FakeClient:
|
|
275
|
+
def __init__(self, **kwargs):
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
async def ask(self, repo, question, on_chunk=None, on_reasoning=None):
|
|
279
|
+
return "hello"
|
|
280
|
+
|
|
281
|
+
monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
|
|
282
|
+
result = runner.invoke(zread_app, ["ask", "owner/example", "q?", "--show-reasoning", "--json"])
|
|
283
|
+
assert result.exit_code == 0
|
|
284
|
+
assert "--show-reasoning has no effect with --json" in result.output
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_ask_stream_show_reasoning(monkeypatch):
|
|
288
|
+
class FakeClient:
|
|
289
|
+
def __init__(self, **kwargs):
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
async def ask(self, repo, question, on_chunk=None, on_reasoning=None):
|
|
293
|
+
if on_reasoning is not None:
|
|
294
|
+
on_reasoning("think")
|
|
295
|
+
if on_chunk is not None:
|
|
296
|
+
on_chunk("hello")
|
|
297
|
+
return "hello"
|
|
298
|
+
|
|
299
|
+
monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
|
|
300
|
+
result = runner.invoke(zread_app, ["ask", "owner/example", "q?", "--stream", "--show-reasoning"])
|
|
301
|
+
assert result.exit_code == 0
|
|
302
|
+
assert "reasoning:" in result.output
|
|
303
|
+
assert "answer:" in result.output
|
|
304
|
+
assert "think" in result.output
|
|
305
|
+
assert "hello" in result.output
|
|
306
|
+
|
|
307
|
+
|
|
172
308
|
def test_cp_concurrency_zero_fails(monkeypatch):
|
|
173
309
|
monkeypatch.setenv("REPOWIKI_ZREAD_MOCK", "- Overview")
|
|
174
310
|
result = runner.invoke(zread_app, ["cp", "owner/example", "--concurrency", "0"])
|
|
@@ -297,6 +297,40 @@ def test_parse_sse_body_errors_on_error_event():
|
|
|
297
297
|
_parse_sse_body(["event: error", "data: {}"])
|
|
298
298
|
|
|
299
299
|
|
|
300
|
+
def test_parse_sse_body_streams_answer_chunks():
|
|
301
|
+
chunks = []
|
|
302
|
+
lines = [
|
|
303
|
+
"event: answer",
|
|
304
|
+
'data: {"text": "hel"}',
|
|
305
|
+
"event: answer",
|
|
306
|
+
'data: {"text": "lo"}',
|
|
307
|
+
"event: round_finish",
|
|
308
|
+
'data: {"text": "hello"}',
|
|
309
|
+
"event: finish",
|
|
310
|
+
"data: {}",
|
|
311
|
+
]
|
|
312
|
+
assert _parse_sse_body(lines, on_chunk=chunks.append) == "hello"
|
|
313
|
+
assert chunks == ["hel", "lo"]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_parse_sse_body_streams_reasoning():
|
|
317
|
+
reasoning = []
|
|
318
|
+
lines = [
|
|
319
|
+
"event: answer",
|
|
320
|
+
'data: {"text": "", "reasoning_content": "think "}',
|
|
321
|
+
"event: answer",
|
|
322
|
+
'data: {"text": "", "reasoning_content": "hard"}',
|
|
323
|
+
"event: answer",
|
|
324
|
+
'data: {"text": "ans", "reasoning_content": ""}',
|
|
325
|
+
"event: round_finish",
|
|
326
|
+
'data: {"text": "answer"}',
|
|
327
|
+
"event: finish",
|
|
328
|
+
"data: {}",
|
|
329
|
+
]
|
|
330
|
+
assert _parse_sse_body(lines, on_reasoning=reasoning.append) == "answer"
|
|
331
|
+
assert reasoning == ["think ", "hard"]
|
|
332
|
+
|
|
333
|
+
|
|
300
334
|
def test_ask_requires_token():
|
|
301
335
|
client = ZreadClient(transport=httpx.MockTransport(lambda r: httpx.Response(200)), token=None)
|
|
302
336
|
with pytest.raises(ZreadError, match="ZREAD_TOKEN"):
|
|
@@ -337,6 +371,67 @@ def test_ask_flow(monkeypatch):
|
|
|
337
371
|
assert seen["body"]["context"]["wiki"] == {"page_id": "p0", "wiki_id": "w1"}
|
|
338
372
|
|
|
339
373
|
|
|
374
|
+
def test_ask_streams_chunks(monkeypatch):
|
|
375
|
+
import repowiki.services.zread.client as mod
|
|
376
|
+
|
|
377
|
+
monkeypatch.setattr(mod.asyncio, "sleep", _no_sleep)
|
|
378
|
+
|
|
379
|
+
sse = (
|
|
380
|
+
'event: answer\ndata: {"text": "hel"}\n\n'
|
|
381
|
+
'event: answer\ndata: {"text": "lo"}\n\n'
|
|
382
|
+
'event: round_finish\ndata: {"text": "hello"}\n\n'
|
|
383
|
+
'event: finish\ndata: {}\n\n'
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
def handler(request):
|
|
387
|
+
url = str(request.url)
|
|
388
|
+
if url.endswith("/api/v1/talk"):
|
|
389
|
+
return httpx.Response(200, json={"code": 0, "data": {"talk_id": "t1"}})
|
|
390
|
+
if "/message" in url:
|
|
391
|
+
return httpx.Response(200, content=sse.encode(), headers={"content-type": "text/event-stream"})
|
|
392
|
+
if "repo/github" in url:
|
|
393
|
+
return httpx.Response(200, json={"code": 0, "data": {"repo_id": "r1", "wiki_id": "w1"}})
|
|
394
|
+
if "/api/v1/wiki/" in url:
|
|
395
|
+
return httpx.Response(200, json=_wiki_json(1))
|
|
396
|
+
return httpx.Response(404)
|
|
397
|
+
|
|
398
|
+
client = ZreadClient(transport=httpx.MockTransport(handler), token="tok")
|
|
399
|
+
chunks = []
|
|
400
|
+
answer = run_async(client.ask("o/r", "q?", on_chunk=chunks.append))
|
|
401
|
+
assert answer == "hello"
|
|
402
|
+
assert chunks == ["hel", "lo"]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_talk_threads_messages(monkeypatch):
|
|
406
|
+
import repowiki.services.zread.client as mod
|
|
407
|
+
|
|
408
|
+
monkeypatch.setattr(mod.asyncio, "sleep", _no_sleep)
|
|
409
|
+
parents = []
|
|
410
|
+
|
|
411
|
+
def handler(request):
|
|
412
|
+
url = str(request.url)
|
|
413
|
+
if url.endswith("/api/v1/talk"):
|
|
414
|
+
return httpx.Response(200, json={"code": 0, "data": {"talk_id": "t1"}})
|
|
415
|
+
if "/message" in url:
|
|
416
|
+
parents.append(json.loads(request.content)["parent_message_id"])
|
|
417
|
+
sse = (
|
|
418
|
+
'event: round_finish\ndata: {"id": "m1", "text": "first"}\n\n'
|
|
419
|
+
'event: finish\ndata: {"id": "m1"}\n\n'
|
|
420
|
+
)
|
|
421
|
+
return httpx.Response(200, content=sse.encode(), headers={"content-type": "text/event-stream"})
|
|
422
|
+
if "repo/github" in url:
|
|
423
|
+
return httpx.Response(200, json={"code": 0, "data": {"repo_id": "r1", "wiki_id": "w1"}})
|
|
424
|
+
if "/api/v1/wiki/" in url:
|
|
425
|
+
return httpx.Response(200, json=_wiki_json(1))
|
|
426
|
+
return httpx.Response(404)
|
|
427
|
+
|
|
428
|
+
client = ZreadClient(transport=httpx.MockTransport(handler), token="tok")
|
|
429
|
+
talk = run_async(client.start_talk("o/r"))
|
|
430
|
+
assert run_async(talk.ask("q1?")) == "first"
|
|
431
|
+
assert run_async(talk.ask("q2?")) == "first"
|
|
432
|
+
assert parents == ["", "m1"]
|
|
433
|
+
|
|
434
|
+
|
|
340
435
|
def test_is_cert_error_detects_ssl_verification():
|
|
341
436
|
from repowiki.services.zread.client import _is_cert_error
|
|
342
437
|
|
|
@@ -600,6 +600,40 @@ crypto = [
|
|
|
600
600
|
{ name = "cryptography" },
|
|
601
601
|
]
|
|
602
602
|
|
|
603
|
+
[[package]]
|
|
604
|
+
name = "pyrepowiki-cli"
|
|
605
|
+
version = "0.1.0"
|
|
606
|
+
source = { editable = "." }
|
|
607
|
+
dependencies = [
|
|
608
|
+
{ name = "httpx" },
|
|
609
|
+
{ name = "mcp" },
|
|
610
|
+
{ name = "rich" },
|
|
611
|
+
{ name = "typer" },
|
|
612
|
+
{ name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
|
613
|
+
{ name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
|
614
|
+
]
|
|
615
|
+
|
|
616
|
+
[package.dev-dependencies]
|
|
617
|
+
dev = [
|
|
618
|
+
{ name = "pytest" },
|
|
619
|
+
{ name = "pytest-asyncio" },
|
|
620
|
+
]
|
|
621
|
+
|
|
622
|
+
[package.metadata]
|
|
623
|
+
requires-dist = [
|
|
624
|
+
{ name = "httpx", specifier = ">=0.27.0" },
|
|
625
|
+
{ name = "mcp", specifier = ">=1.0.0,<2.0.0" },
|
|
626
|
+
{ name = "rich", specifier = ">=13.0.0" },
|
|
627
|
+
{ name = "typer", specifier = ">=0.12.0" },
|
|
628
|
+
{ name = "websockets", specifier = ">=13.0.0" },
|
|
629
|
+
]
|
|
630
|
+
|
|
631
|
+
[package.metadata.requires-dev]
|
|
632
|
+
dev = [
|
|
633
|
+
{ name = "pytest", specifier = ">=8.0.0" },
|
|
634
|
+
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
|
|
635
|
+
]
|
|
636
|
+
|
|
603
637
|
[[package]]
|
|
604
638
|
name = "pytest"
|
|
605
639
|
version = "9.1.1"
|
|
@@ -690,40 +724,6 @@ wheels = [
|
|
|
690
724
|
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
|
691
725
|
]
|
|
692
726
|
|
|
693
|
-
[[package]]
|
|
694
|
-
name = "repowiki-cli"
|
|
695
|
-
version = "0.1.0"
|
|
696
|
-
source = { editable = "." }
|
|
697
|
-
dependencies = [
|
|
698
|
-
{ name = "httpx" },
|
|
699
|
-
{ name = "mcp" },
|
|
700
|
-
{ name = "rich" },
|
|
701
|
-
{ name = "typer" },
|
|
702
|
-
{ name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
|
703
|
-
{ name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
|
704
|
-
]
|
|
705
|
-
|
|
706
|
-
[package.dev-dependencies]
|
|
707
|
-
dev = [
|
|
708
|
-
{ name = "pytest" },
|
|
709
|
-
{ name = "pytest-asyncio" },
|
|
710
|
-
]
|
|
711
|
-
|
|
712
|
-
[package.metadata]
|
|
713
|
-
requires-dist = [
|
|
714
|
-
{ name = "httpx", specifier = ">=0.27.0" },
|
|
715
|
-
{ name = "mcp", specifier = ">=1.0.0,<2.0.0" },
|
|
716
|
-
{ name = "rich", specifier = ">=13.0.0" },
|
|
717
|
-
{ name = "typer", specifier = ">=0.12.0" },
|
|
718
|
-
{ name = "websockets", specifier = ">=13.0.0" },
|
|
719
|
-
]
|
|
720
|
-
|
|
721
|
-
[package.metadata.requires-dev]
|
|
722
|
-
dev = [
|
|
723
|
-
{ name = "pytest", specifier = ">=8.0.0" },
|
|
724
|
-
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
|
|
725
|
-
]
|
|
726
|
-
|
|
727
727
|
[[package]]
|
|
728
728
|
name = "rich"
|
|
729
729
|
version = "15.0.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|