openwiki-server 0.1.0__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.
- openwiki_engine/__init__.py +3 -0
- openwiki_engine/adapters/__init__.py +1 -0
- openwiki_engine/adapters/openwiki.py +342 -0
- openwiki_engine/application/__init__.py +1 -0
- openwiki_engine/application/service.py +481 -0
- openwiki_engine/config.py +80 -0
- openwiki_engine/domain/__init__.py +18 -0
- openwiki_engine/domain/ids.py +30 -0
- openwiki_engine/domain/merge.py +43 -0
- openwiki_engine/domain/models.py +117 -0
- openwiki_engine/domain/wiki_config.py +68 -0
- openwiki_engine/errors.py +50 -0
- openwiki_engine/interfaces/__init__.py +1 -0
- openwiki_engine/interfaces/celery_app.py +116 -0
- openwiki_engine/interfaces/cli.py +309 -0
- openwiki_engine/interfaces/grpc_server.py +253 -0
- openwiki_engine/interfaces/http_app.py +203 -0
- openwiki_engine/interfaces/mcp_server.py +147 -0
- openwiki_engine/persistence/__init__.py +1 -0
- openwiki_engine/persistence/sqlite_store.py +459 -0
- openwiki_engine/protocol.py +31 -0
- openwiki_engine/runtime.py +45 -0
- openwiki_server-0.1.0.dist-info/METADATA +269 -0
- openwiki_server-0.1.0.dist-info/RECORD +40 -0
- openwiki_server-0.1.0.dist-info/WHEEL +4 -0
- openwiki_server-0.1.0.dist-info/entry_points.txt +2 -0
- openwiki_server-0.1.0.dist-info/licenses/LICENSE +21 -0
- openwiki_server_sdk/__init__.py +83 -0
- openwiki_server_sdk/_bootstrap.py +71 -0
- openwiki_server_sdk/_version.py +1 -0
- openwiki_server_sdk/async_client.py +287 -0
- openwiki_server_sdk/client.py +306 -0
- openwiki_server_sdk/envelope.py +39 -0
- openwiki_server_sdk/errors.py +80 -0
- openwiki_server_sdk/headers.py +42 -0
- openwiki_server_sdk/models/__init__.py +3 -0
- openwiki_server_sdk/models/wiki.py +506 -0
- openwiki_server_sdk/trace.py +20 -0
- openwiki_server_sdk/transport.py +207 -0
- openwiki_server_sdk/transport_async.py +147 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""适配层:OpenWiki CLI 子进程适配 + OKF 解析 + 规则切页(守卫式导入)。"""
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""OpenWiki 内核适配层。
|
|
2
|
+
|
|
3
|
+
- CLI 子进程调用:`openwiki personal --update`(非交互自动退出),HOME 指向 wiki
|
|
4
|
+
实例根目录实现多租户隔离(openwiki 配置目录为 ~/.openwiki);
|
|
5
|
+
- OKF v0.2 Markdown 解析:front matter(type/tags/sources/status/扩展字段)+ 正文 +
|
|
6
|
+
页面互链;
|
|
7
|
+
- 规则切页生成:LLM/内核不可用时按 wikiConfig.granularity 确定性切页,保证离线可用。
|
|
8
|
+
|
|
9
|
+
所有 openwiki 相关导入/调用均为守卫式,核心能力不依赖其可用性。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ..config import Settings
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("openwiki_engine.openwiki")
|
|
25
|
+
|
|
26
|
+
RESERVED_DOCS = {"index.md", "log.md"}
|
|
27
|
+
FRONT_MATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)
|
|
28
|
+
WIKI_LINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|[^\]|]+)?\]\]")
|
|
29
|
+
MD_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+\.md)\)")
|
|
30
|
+
FIELD_RE = re.compile(r"^\s*([\u4e00-\u9fa5\w]+)[::]\s*(.+?)\s*$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def openwiki_bin(settings: Settings | None = None) -> str:
|
|
34
|
+
settings = settings or Settings()
|
|
35
|
+
return settings.openwiki_bin_resolved()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def openwiki_available(settings: Settings | None = None) -> bool:
|
|
39
|
+
settings = settings or Settings()
|
|
40
|
+
if not settings.openwiki_enabled:
|
|
41
|
+
return False
|
|
42
|
+
binary = openwiki_bin(settings)
|
|
43
|
+
if "/" in binary:
|
|
44
|
+
return Path(binary).exists()
|
|
45
|
+
return shutil.which(binary) is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_update(
|
|
49
|
+
wiki_root: str,
|
|
50
|
+
*,
|
|
51
|
+
provider: str = "openai",
|
|
52
|
+
model_id: str = "",
|
|
53
|
+
message: str = "",
|
|
54
|
+
timeout: int = 600,
|
|
55
|
+
settings: Settings | None = None,
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""运行 `openwiki personal --update <message>`(非交互,stdin 关闭自动退出)。"""
|
|
58
|
+
binary = openwiki_bin(settings)
|
|
59
|
+
cmd = [binary, "personal", "--update"]
|
|
60
|
+
if message:
|
|
61
|
+
cmd.append(message)
|
|
62
|
+
env = dict(os.environ)
|
|
63
|
+
env.update(
|
|
64
|
+
{
|
|
65
|
+
"HOME": wiki_root,
|
|
66
|
+
"OPENWIKI_TELEMETRY_DISABLED": "1",
|
|
67
|
+
"OPENWIKI_PROVIDER": provider,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
if model_id:
|
|
71
|
+
env["OPENWIKI_MODEL_ID"] = model_id
|
|
72
|
+
try:
|
|
73
|
+
Path(wiki_root).mkdir(parents=True, exist_ok=True)
|
|
74
|
+
proc = subprocess.run(
|
|
75
|
+
cmd,
|
|
76
|
+
cwd=wiki_root,
|
|
77
|
+
env=env,
|
|
78
|
+
stdin=subprocess.DEVNULL,
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
timeout=timeout,
|
|
82
|
+
)
|
|
83
|
+
except subprocess.TimeoutExpired:
|
|
84
|
+
logger.warning("openwiki update 超时(%ss):%s", timeout, wiki_root)
|
|
85
|
+
return {"ok": False, "reason": f"openwiki update 超时({timeout}s)", "returncode": -1}
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
return {"ok": False, "reason": "openwiki 可执行文件不存在", "returncode": -1}
|
|
88
|
+
ok_flag = proc.returncode == 0
|
|
89
|
+
if not ok_flag:
|
|
90
|
+
logger.warning(
|
|
91
|
+
"openwiki update 失败 rc=%s:%s", proc.returncode, (proc.stderr or proc.stdout)[-2000:]
|
|
92
|
+
)
|
|
93
|
+
return {
|
|
94
|
+
"ok": ok_flag,
|
|
95
|
+
"returncode": proc.returncode,
|
|
96
|
+
"stdout": (proc.stdout or "")[-4000:],
|
|
97
|
+
"stderr": (proc.stderr or "")[-4000:],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_ingest(
|
|
102
|
+
wiki_root: str,
|
|
103
|
+
connector: str,
|
|
104
|
+
*,
|
|
105
|
+
settings: Settings | None = None,
|
|
106
|
+
timeout: int = 600,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
"""运行 `openwiki ingest <connector>`。"""
|
|
109
|
+
binary = openwiki_bin(settings)
|
|
110
|
+
cmd = [binary, "ingest", connector]
|
|
111
|
+
env = dict(os.environ)
|
|
112
|
+
env.update({"HOME": wiki_root, "OPENWIKI_TELEMETRY_DISABLED": "1"})
|
|
113
|
+
try:
|
|
114
|
+
Path(wiki_root).mkdir(parents=True, exist_ok=True)
|
|
115
|
+
proc = subprocess.run(
|
|
116
|
+
cmd,
|
|
117
|
+
cwd=wiki_root,
|
|
118
|
+
env=env,
|
|
119
|
+
stdin=subprocess.DEVNULL,
|
|
120
|
+
capture_output=True,
|
|
121
|
+
text=True,
|
|
122
|
+
timeout=timeout,
|
|
123
|
+
)
|
|
124
|
+
except subprocess.TimeoutExpired:
|
|
125
|
+
return {"ok": False, "reason": f"openwiki ingest 超时({timeout}s)", "returncode": -1}
|
|
126
|
+
except FileNotFoundError:
|
|
127
|
+
return {"ok": False, "reason": "openwiki 可执行文件不存在", "returncode": -1}
|
|
128
|
+
return {
|
|
129
|
+
"ok": proc.returncode == 0,
|
|
130
|
+
"returncode": proc.returncode,
|
|
131
|
+
"stdout": (proc.stdout or "")[-4000:],
|
|
132
|
+
"stderr": (proc.stderr or "")[-4000:],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------- OKF 解析 ----------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_front_matter(raw: str) -> tuple[dict[str, Any], str]:
|
|
140
|
+
"""解析 YAML front matter 最小子集(key: value / 数组 / 内联对象),缺省降级为空 dict。"""
|
|
141
|
+
try:
|
|
142
|
+
import yaml # type: ignore
|
|
143
|
+
|
|
144
|
+
data = yaml.safe_load(raw) or {}
|
|
145
|
+
return (data if isinstance(data, dict) else {}), raw
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
fields: dict[str, Any] = {}
|
|
149
|
+
for line in raw.splitlines():
|
|
150
|
+
line = line.strip()
|
|
151
|
+
if not line or line.startswith("#"):
|
|
152
|
+
continue
|
|
153
|
+
if ":" not in line:
|
|
154
|
+
continue
|
|
155
|
+
key, _, value = line.partition(":")
|
|
156
|
+
key = key.strip()
|
|
157
|
+
value = value.strip()
|
|
158
|
+
if value.startswith("[") and value.endswith("]"):
|
|
159
|
+
value = [item.strip().strip("'\"") for item in value[1:-1].split(",") if item.strip()]
|
|
160
|
+
fields[key] = value
|
|
161
|
+
return fields, raw
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _extract_links(markdown: str, kb_id: str) -> list[dict[str, str]]:
|
|
165
|
+
from ..domain.ids import normalize_title, page_id
|
|
166
|
+
|
|
167
|
+
links: list[dict[str, str]] = []
|
|
168
|
+
for match in WIKI_LINK_RE.findall(markdown):
|
|
169
|
+
title = match.strip()
|
|
170
|
+
if title:
|
|
171
|
+
links.append({"title": title, "pageId": page_id(kb_id, normalize_title(title))})
|
|
172
|
+
for title, target in MD_LINK_RE.findall(markdown):
|
|
173
|
+
target = target.strip()
|
|
174
|
+
if target in RESERVED_DOCS:
|
|
175
|
+
continue
|
|
176
|
+
stable_key = normalize_title(target.rsplit("/", 1)[-1].removesuffix(".md"))
|
|
177
|
+
links.append({"title": title.strip() or stable_key, "pageId": page_id(kb_id, stable_key)})
|
|
178
|
+
dedup: dict[str, dict[str, str]] = {}
|
|
179
|
+
for link in links:
|
|
180
|
+
dedup[link["title"]] = link
|
|
181
|
+
return sorted(dedup.values(), key=lambda item: item["title"])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _source_docs(front: dict[str, Any]) -> list[str]:
|
|
185
|
+
sources = front.get("sources") or []
|
|
186
|
+
doc_ids: list[str] = []
|
|
187
|
+
for item in sources if isinstance(sources, list) else [sources]:
|
|
188
|
+
if isinstance(item, dict):
|
|
189
|
+
value = str(item.get("docId") or item.get("doc_id") or item.get("id") or "").strip()
|
|
190
|
+
else:
|
|
191
|
+
value = str(item).strip()
|
|
192
|
+
if value and value not in doc_ids:
|
|
193
|
+
doc_ids.append(value)
|
|
194
|
+
return doc_ids
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def parse_okf_pages(wiki_dir: str, *, kb_id: str = "") -> list[dict[str, Any]]:
|
|
198
|
+
"""扫描 OKF Markdown 目录,返回页面数据 dict 列表(index.md/log.md 跳过)。"""
|
|
199
|
+
root = Path(wiki_dir)
|
|
200
|
+
if not root.exists():
|
|
201
|
+
return []
|
|
202
|
+
pages: list[dict[str, Any]] = []
|
|
203
|
+
for path in sorted(root.glob("**/*.md")):
|
|
204
|
+
if path.name in RESERVED_DOCS or path.name.startswith("."):
|
|
205
|
+
continue
|
|
206
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
207
|
+
front: dict[str, Any] = {}
|
|
208
|
+
body = text
|
|
209
|
+
match = FRONT_MATTER_RE.match(text)
|
|
210
|
+
if match:
|
|
211
|
+
front, _ = _parse_front_matter(match.group(1))
|
|
212
|
+
body = match.group(2).strip()
|
|
213
|
+
title = str(front.get("title") or "").strip() or path.stem
|
|
214
|
+
stable_key = str(front.get("stableKey") or "").strip() or path.stem
|
|
215
|
+
fields = {
|
|
216
|
+
str(key).strip(): value
|
|
217
|
+
for key, value in front.items()
|
|
218
|
+
if str(key) not in ("title", "type", "generated", "tags", "sources", "status", "stale_after", "stableKey")
|
|
219
|
+
}
|
|
220
|
+
status = str(front.get("status") or "active").strip().lower()
|
|
221
|
+
if status not in ("active", "deprecated"):
|
|
222
|
+
status = "active"
|
|
223
|
+
pages.append(
|
|
224
|
+
{
|
|
225
|
+
"file": str(path.relative_to(root)),
|
|
226
|
+
"title": title,
|
|
227
|
+
"stableKey": stable_key,
|
|
228
|
+
"type": str(front.get("type") or "concept"),
|
|
229
|
+
"tags": [str(x) for x in (front.get("tags") or []) if str(x).strip()],
|
|
230
|
+
"fields": fields,
|
|
231
|
+
"sourceDocs": _source_docs(front),
|
|
232
|
+
"status": status,
|
|
233
|
+
"markdown": body,
|
|
234
|
+
"links": _extract_links(body, kb_id) if kb_id else [],
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
return pages
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------- 规则切页(离线降级) ----------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def extract_fields(markdown: str, keys: list[str]) -> dict[str, Any]:
|
|
244
|
+
"""按 extractFields 白名单抽取结构化字段(`字段:值` 行)。"""
|
|
245
|
+
if not keys:
|
|
246
|
+
return {}
|
|
247
|
+
found: dict[str, Any] = {}
|
|
248
|
+
for line in markdown.splitlines():
|
|
249
|
+
match = FIELD_RE.match(line)
|
|
250
|
+
if not match:
|
|
251
|
+
continue
|
|
252
|
+
key = match.group(1).strip()
|
|
253
|
+
if key in keys:
|
|
254
|
+
found[key] = match.group(2).strip()
|
|
255
|
+
return found
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _heading_level(line: str) -> int:
|
|
259
|
+
level = len(line) - len(line.lstrip("#"))
|
|
260
|
+
return level if 1 <= level <= 6 else 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def generate_pages_rule(
|
|
264
|
+
markdown: str,
|
|
265
|
+
*,
|
|
266
|
+
title: str,
|
|
267
|
+
tags: list[str] | None = None,
|
|
268
|
+
config: dict[str, Any] | None = None,
|
|
269
|
+
) -> list[dict[str, Any]]:
|
|
270
|
+
"""按 granularity 确定性切页:
|
|
271
|
+
- page:整文档一页;
|
|
272
|
+
- heading/section:按 h1/h2 标题切页(h1 为根页,h2 挂 h1 之下);
|
|
273
|
+
- auto:有 h1 按 section,否则按 heading,无标题回退 page。
|
|
274
|
+
返回页面 dict(不含 pageId,由 service 派生稳定 ID)。
|
|
275
|
+
"""
|
|
276
|
+
config = dict(config or {})
|
|
277
|
+
granularity = str(config.get("granularity") or "auto").lower()
|
|
278
|
+
extract_keys = [str(x) for x in (config.get("extractFields") or [])]
|
|
279
|
+
tags = list(tags or [])
|
|
280
|
+
text = markdown.strip()
|
|
281
|
+
if not text:
|
|
282
|
+
text = f"# {title}"
|
|
283
|
+
|
|
284
|
+
if granularity == "page" or granularity == "auto" and not re.search(r"^#{1,2}\s", text, re.MULTILINE):
|
|
285
|
+
stable_key = title.strip() or "未命名页面"
|
|
286
|
+
return [
|
|
287
|
+
{
|
|
288
|
+
"title": title.strip() or "未命名页面",
|
|
289
|
+
"stableKey": stable_key,
|
|
290
|
+
"level": 1,
|
|
291
|
+
"parentStableKey": "",
|
|
292
|
+
"tags": tags,
|
|
293
|
+
"fields": extract_fields(text, extract_keys),
|
|
294
|
+
"markdown": text,
|
|
295
|
+
}
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
lines = text.splitlines()
|
|
299
|
+
sections: list[dict[str, Any]] = []
|
|
300
|
+
current: dict[str, Any] | None = None
|
|
301
|
+
stack: list[dict[str, Any]] = []
|
|
302
|
+
for line in lines:
|
|
303
|
+
level = _heading_level(line)
|
|
304
|
+
if level in (1, 2):
|
|
305
|
+
heading_text = line.lstrip("#").strip()
|
|
306
|
+
current = {
|
|
307
|
+
"title": heading_text or "未命名章节",
|
|
308
|
+
"stableKey": heading_text,
|
|
309
|
+
"level": level,
|
|
310
|
+
"parentStableKey": "",
|
|
311
|
+
"tags": tags,
|
|
312
|
+
"fields": {},
|
|
313
|
+
"markdown": [line],
|
|
314
|
+
}
|
|
315
|
+
sections.append(current)
|
|
316
|
+
while stack and stack[-1]["level"] >= level:
|
|
317
|
+
stack.pop()
|
|
318
|
+
if stack:
|
|
319
|
+
current["parentStableKey"] = stack[-1]["stableKey"]
|
|
320
|
+
stack.append(current)
|
|
321
|
+
elif current is not None:
|
|
322
|
+
current["markdown"].append(line)
|
|
323
|
+
elif line.strip():
|
|
324
|
+
current = {
|
|
325
|
+
"title": title.strip() or "未命名页面",
|
|
326
|
+
"stableKey": title,
|
|
327
|
+
"level": 1,
|
|
328
|
+
"parentStableKey": "",
|
|
329
|
+
"tags": tags,
|
|
330
|
+
"fields": {},
|
|
331
|
+
"markdown": [line],
|
|
332
|
+
}
|
|
333
|
+
sections.append(current)
|
|
334
|
+
stack = [current]
|
|
335
|
+
|
|
336
|
+
if not sections:
|
|
337
|
+
return generate_pages_rule(text, title=title, tags=tags, config={**config, "granularity": "page"})
|
|
338
|
+
|
|
339
|
+
for section in sections:
|
|
340
|
+
section["markdown"] = "\n".join(section["markdown"]).strip()
|
|
341
|
+
section["fields"] = extract_fields(section["markdown"], extract_keys)
|
|
342
|
+
return sections
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""应用层:OpenWikiService — HTTP/gRPC/Celery/MCP/CLI 五面共用的用例编排。"""
|