zim-pro 0.2.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.
- zim_pro/__init__.py +36 -0
- zim_pro/ai.py +183 -0
- zim_pro/api/__init__.py +0 -0
- zim_pro/api/ai.py +620 -0
- zim_pro/api/catalog.py +146 -0
- zim_pro/api/content.py +119 -0
- zim_pro/api/downloads.py +86 -0
- zim_pro/api/library.py +255 -0
- zim_pro/catalog/__init__.py +0 -0
- zim_pro/catalog/opds.py +103 -0
- zim_pro/catalog/updates.py +52 -0
- zim_pro/config.py +56 -0
- zim_pro/context.py +47 -0
- zim_pro/db.py +269 -0
- zim_pro/downloads/__init__.py +0 -0
- zim_pro/downloads/engine.py +314 -0
- zim_pro/downloads/manager.py +373 -0
- zim_pro/downloads/metalink.py +69 -0
- zim_pro/events.py +42 -0
- zim_pro/library/__init__.py +0 -0
- zim_pro/library/archive_pool.py +35 -0
- zim_pro/library/article.py +264 -0
- zim_pro/library/naming.py +44 -0
- zim_pro/library/reader.py +115 -0
- zim_pro/library/scanner.py +180 -0
- zim_pro/library/search.py +71 -0
- zim_pro/main.py +88 -0
- zim_pro/mcp_server.py +186 -0
- zim_pro/providers.py +573 -0
- zim_pro/retrieval.py +534 -0
- zim_pro/scheduler.py +62 -0
- zim_pro/web/apple-touch-icon.png +0 -0
- zim_pro/web/assets/index-C7MC6g9M.css +2 -0
- zim_pro/web/assets/index-CdDxwSvD.js +52 -0
- zim_pro/web/icon-192.png +0 -0
- zim_pro/web/icon-maskable.png +0 -0
- zim_pro/web/icon.png +0 -0
- zim_pro/web/index.html +35 -0
- zim_pro/web/manifest.webmanifest +14 -0
- zim_pro/websearch.py +85 -0
- zim_pro-0.2.1.dist-info/METADATA +304 -0
- zim_pro-0.2.1.dist-info/RECORD +44 -0
- zim_pro-0.2.1.dist-info/WHEEL +4 -0
- zim_pro-0.2.1.dist-info/entry_points.txt +4 -0
zim_pro/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
parser = argparse.ArgumentParser(
|
|
8
|
+
prog="zim-pro", description="ZIM library server, updater and MCP server."
|
|
9
|
+
)
|
|
10
|
+
commands = parser.add_subparsers(dest="command")
|
|
11
|
+
commands.add_parser("serve", help="run the web server (default)")
|
|
12
|
+
commands.add_parser("mcp", help="run the MCP server over stdio, for desktop LLM apps")
|
|
13
|
+
args = parser.parse_args()
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
|
|
17
|
+
config = Config()
|
|
18
|
+
if args.command == "mcp":
|
|
19
|
+
# stdout carries the protocol, so logs must stay on stderr.
|
|
20
|
+
logging.basicConfig(level=logging.WARNING)
|
|
21
|
+
from .main import build_context
|
|
22
|
+
from .mcp_server import build_mcp
|
|
23
|
+
|
|
24
|
+
ctx = build_context(config)
|
|
25
|
+
ctx.scanner.scan()
|
|
26
|
+
asyncio.run(build_mcp(ctx).run_stdio_async())
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
import uvicorn
|
|
30
|
+
|
|
31
|
+
from .main import create_app
|
|
32
|
+
|
|
33
|
+
logging.basicConfig(
|
|
34
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
|
35
|
+
)
|
|
36
|
+
uvicorn.run(create_app(config), host=config.host, port=config.port, log_level="info")
|
zim_pro/ai.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Ask AI: the prompts, sources and messages around an answer (see retrieval.py for search).
|
|
2
|
+
|
|
3
|
+
The flow is retrieval-augmented rather than tool-calling, because small local models
|
|
4
|
+
are unreliable at calling tools: the model first turns the question into keyword
|
|
5
|
+
searches, the library is searched, the best passages are put in the prompt, and the
|
|
6
|
+
answer is streamed back with numbered citations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from .providers import AIError, split_reasoning
|
|
15
|
+
from .retrieval import is_small_talk, keywords
|
|
16
|
+
|
|
17
|
+
__all__ = ["AIError", "Source", "is_small_talk", "keywords"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
HISTORY_TURNS = 6
|
|
21
|
+
|
|
22
|
+
CHAT_PROMPT = """\
|
|
23
|
+
You are the assistant built into the user's offline library (Wikipedia, Stack Exchange, \
|
|
24
|
+
software documentation and similar). The user's message needed no library search. Reply \
|
|
25
|
+
briefly and naturally. If they ask what you can do, say you answer questions from the \
|
|
26
|
+
books in their library and cite the articles you used."""
|
|
27
|
+
|
|
28
|
+
ANSWER_PROMPT = """\
|
|
29
|
+
You are a research assistant answering questions from the user's offline library.
|
|
30
|
+
Numbered sources follow the question: articles from the library, and web pages when the \
|
|
31
|
+
library was not enough. Base your answer on them and cite the ones you use inline as [1], \
|
|
32
|
+
[2]. If the sources do not contain the answer, say so plainly, then answer from your own \
|
|
33
|
+
knowledge and say that you are doing so. Be direct and well organised, and use Markdown."""
|
|
34
|
+
|
|
35
|
+
WEB_PROMPT = """\
|
|
36
|
+
You are a research assistant. The user's offline library had nothing useful for this \
|
|
37
|
+
question, so answer it by searching the web. Be direct and well organised, use Markdown, \
|
|
38
|
+
and cite the pages you rely on."""
|
|
39
|
+
|
|
40
|
+
FORMAT_RULES = """
|
|
41
|
+
|
|
42
|
+
Formatting rules:
|
|
43
|
+
- Write GitHub-flavoured Markdown. Never use HTML tags.
|
|
44
|
+
- Start with the answer itself. No preamble such as "Answer:" and do not repeat the question.
|
|
45
|
+
- Keep paragraphs short. Leave a blank line between paragraphs, lists, tables and code blocks.
|
|
46
|
+
- For steps or several items use "-" bullets or "1." numbered lists, one item per line.
|
|
47
|
+
- Use "## " headings only when the answer has several sections, and never a "# " title.
|
|
48
|
+
- Put code and commands in fenced blocks with the language named, and always close the fence.
|
|
49
|
+
- Use a table only to compare things, with a header row and a |---| row under it.
|
|
50
|
+
- Use **bold** sparingly, for key terms only.
|
|
51
|
+
- Do not wrap the whole answer in a code block, and do not show your reasoning."""
|
|
52
|
+
|
|
53
|
+
CITATION_RULES = """
|
|
54
|
+
- Cite as [1], [2] right after the sentence the source supports: one number per bracket, \
|
|
55
|
+
never [1, 2], footnotes or "Source 1".
|
|
56
|
+
- Do not list the sources at the end; they are shown next to your answer."""
|
|
57
|
+
|
|
58
|
+
# Below this share of matched search terms, the library is treated as not having an answer.
|
|
59
|
+
ENOUGH_COVERAGE = 0.5
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Source:
|
|
64
|
+
"""A passage the answer may cite: an article from the library, or a web page."""
|
|
65
|
+
|
|
66
|
+
n: int
|
|
67
|
+
kind: str # "library" or "web"
|
|
68
|
+
title: str
|
|
69
|
+
excerpt: str
|
|
70
|
+
book: str = ""
|
|
71
|
+
book_title: str = ""
|
|
72
|
+
path: str = ""
|
|
73
|
+
url: str = ""
|
|
74
|
+
site: str = ""
|
|
75
|
+
# Share of the search terms the article contains; low means a weak match.
|
|
76
|
+
coverage: float = 0.0
|
|
77
|
+
# Pictures, videos and recordings on the page, shown beside the source.
|
|
78
|
+
media: list[dict[str, str]] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def system_prompt(base: str, citing: bool, instructions: str = "") -> str:
|
|
82
|
+
"""A system prompt with the formatting rules and the user's own instructions added."""
|
|
83
|
+
prompt = base + FORMAT_RULES + (CITATION_RULES if citing else "")
|
|
84
|
+
if instructions.strip():
|
|
85
|
+
prompt += f"\n\nThe user has also asked for this in every answer:\n{instructions.strip()}"
|
|
86
|
+
return prompt
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_messages(
|
|
90
|
+
question: str,
|
|
91
|
+
history: list[dict[str, str]],
|
|
92
|
+
sources: list[Source] | None,
|
|
93
|
+
instructions: str = "",
|
|
94
|
+
) -> list[dict[str, str]]:
|
|
95
|
+
"""The prompt for the answer; ``sources`` is None when no search was made."""
|
|
96
|
+
if sources is None:
|
|
97
|
+
return [
|
|
98
|
+
{"role": "system", "content": system_prompt(CHAT_PROMPT, False, instructions)},
|
|
99
|
+
*history,
|
|
100
|
+
{"role": "user", "content": question},
|
|
101
|
+
]
|
|
102
|
+
if sources:
|
|
103
|
+
listing = "\n\n".join(
|
|
104
|
+
f"[{s.n}] {s.title} — {s.book_title or s.site}\n{s.excerpt}" for s in sources
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
listing = "(The search found nothing relevant.)"
|
|
108
|
+
return [
|
|
109
|
+
{"role": "system", "content": system_prompt(ANSWER_PROMPT, True, instructions)},
|
|
110
|
+
*history,
|
|
111
|
+
{"role": "user", "content": f"{question}\n\nSources:\n\n{listing}"},
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def clean_history(history: list[dict[str, Any]]) -> list[dict[str, str]]:
|
|
116
|
+
turns = [
|
|
117
|
+
# Answers stored before thinking was separated may still carry it inline.
|
|
118
|
+
{"role": m["role"], "content": split_reasoning(str(m["content"]))[1][:6000]}
|
|
119
|
+
for m in history
|
|
120
|
+
if m.get("role") in ("user", "assistant") and m.get("content")
|
|
121
|
+
]
|
|
122
|
+
return turns[-HISTORY_TURNS * 2 :]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def library_sources(passages: list[dict[str, Any]]) -> list[Source]:
|
|
126
|
+
"""Numbered sources from the passages the search kept."""
|
|
127
|
+
return [
|
|
128
|
+
Source(
|
|
129
|
+
n=index,
|
|
130
|
+
kind="library",
|
|
131
|
+
title=item["title"],
|
|
132
|
+
excerpt=item["excerpt"],
|
|
133
|
+
book=item["row"]["id"],
|
|
134
|
+
book_title=item["row"]["title"],
|
|
135
|
+
path=item["path"],
|
|
136
|
+
coverage=item["coverage"],
|
|
137
|
+
media=item.get("media", []),
|
|
138
|
+
)
|
|
139
|
+
for index, item in enumerate(passages, 1)
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def library_is_enough(sources: list[Source]) -> bool:
|
|
144
|
+
"""Whether the library search found something that plausibly answers the question."""
|
|
145
|
+
return any(s.kind == "library" and s.coverage >= ENOUGH_COVERAGE for s in sources)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def web_sources(hits: list[dict[str, str]], start: int) -> list[Source]:
|
|
149
|
+
sources = []
|
|
150
|
+
for index, hit in enumerate(hits):
|
|
151
|
+
site = hit.get("site") or urlsplit(hit["url"]).netloc.removeprefix("www.")
|
|
152
|
+
sources.append(
|
|
153
|
+
Source(
|
|
154
|
+
n=start + index,
|
|
155
|
+
kind="web",
|
|
156
|
+
title=hit.get("title") or site,
|
|
157
|
+
excerpt=hit.get("text", ""),
|
|
158
|
+
url=hit["url"],
|
|
159
|
+
site=site,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
return sources
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def web_messages(
|
|
166
|
+
question: str, history: list[dict[str, str]], instructions: str = ""
|
|
167
|
+
) -> list[dict[str, str]]:
|
|
168
|
+
"""The prompt for a model that searches the web itself."""
|
|
169
|
+
return [
|
|
170
|
+
{"role": "system", "content": system_prompt(WEB_PROMPT, False, instructions)},
|
|
171
|
+
*history,
|
|
172
|
+
{"role": "user", "content": question},
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def public_source(source: Source) -> dict[str, Any]:
|
|
177
|
+
data = asdict(source)
|
|
178
|
+
del data["coverage"]
|
|
179
|
+
# For display only: drop Markdown markup and the title the excerpt usually opens with.
|
|
180
|
+
plain = re.sub(r"[*_`#>|]+|-{3,}|!?\[([^\]]*)\]\([^)]*\)", r"\1", source.excerpt)
|
|
181
|
+
plain = " ".join(plain.split()).removeprefix(source.title).strip()
|
|
182
|
+
data["excerpt"] = plain[:240]
|
|
183
|
+
return data
|
zim_pro/api/__init__.py
ADDED
|
File without changes
|