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.
Files changed (40) hide show
  1. openwiki_engine/__init__.py +3 -0
  2. openwiki_engine/adapters/__init__.py +1 -0
  3. openwiki_engine/adapters/openwiki.py +342 -0
  4. openwiki_engine/application/__init__.py +1 -0
  5. openwiki_engine/application/service.py +481 -0
  6. openwiki_engine/config.py +80 -0
  7. openwiki_engine/domain/__init__.py +18 -0
  8. openwiki_engine/domain/ids.py +30 -0
  9. openwiki_engine/domain/merge.py +43 -0
  10. openwiki_engine/domain/models.py +117 -0
  11. openwiki_engine/domain/wiki_config.py +68 -0
  12. openwiki_engine/errors.py +50 -0
  13. openwiki_engine/interfaces/__init__.py +1 -0
  14. openwiki_engine/interfaces/celery_app.py +116 -0
  15. openwiki_engine/interfaces/cli.py +309 -0
  16. openwiki_engine/interfaces/grpc_server.py +253 -0
  17. openwiki_engine/interfaces/http_app.py +203 -0
  18. openwiki_engine/interfaces/mcp_server.py +147 -0
  19. openwiki_engine/persistence/__init__.py +1 -0
  20. openwiki_engine/persistence/sqlite_store.py +459 -0
  21. openwiki_engine/protocol.py +31 -0
  22. openwiki_engine/runtime.py +45 -0
  23. openwiki_server-0.1.0.dist-info/METADATA +269 -0
  24. openwiki_server-0.1.0.dist-info/RECORD +40 -0
  25. openwiki_server-0.1.0.dist-info/WHEEL +4 -0
  26. openwiki_server-0.1.0.dist-info/entry_points.txt +2 -0
  27. openwiki_server-0.1.0.dist-info/licenses/LICENSE +21 -0
  28. openwiki_server_sdk/__init__.py +83 -0
  29. openwiki_server_sdk/_bootstrap.py +71 -0
  30. openwiki_server_sdk/_version.py +1 -0
  31. openwiki_server_sdk/async_client.py +287 -0
  32. openwiki_server_sdk/client.py +306 -0
  33. openwiki_server_sdk/envelope.py +39 -0
  34. openwiki_server_sdk/errors.py +80 -0
  35. openwiki_server_sdk/headers.py +42 -0
  36. openwiki_server_sdk/models/__init__.py +3 -0
  37. openwiki_server_sdk/models/wiki.py +506 -0
  38. openwiki_server_sdk/trace.py +20 -0
  39. openwiki_server_sdk/transport.py +207 -0
  40. openwiki_server_sdk/transport_async.py +147 -0
@@ -0,0 +1,3 @@
1
+ """OpenWiki-backed wiki engine: HTTP/gRPC/Celery/MCP/CLI 五面接口。"""
2
+
3
+ __version__ = "0.1.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 五面共用的用例编排。"""