mcai-knowledge 0.2.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.
- mcai/__init__.py +3 -0
- mcai/__main__.py +7 -0
- mcai/autopilot.py +229 -0
- mcai/cli.py +558 -0
- mcai/corpus.py +224 -0
- mcai/dashboard.py +229 -0
- mcai/dataset.py +203 -0
- mcai/deploy.py +106 -0
- mcai/disambig.py +71 -0
- mcai/extract.py +757 -0
- mcai/factory.py +223 -0
- mcai/guide.py +83 -0
- mcai/knowledge.py +515 -0
- mcai/llm.py +97 -0
- mcai/manifest.py +171 -0
- mcai/mcp_server.py +280 -0
- mcai/pdftext.py +214 -0
- mcai/pipeline.py +47 -0
- mcai/probe.py +230 -0
- mcai/quality.py +165 -0
- mcai/schema.py +168 -0
- mcai/selftest.py +74 -0
- mcai/server.py +426 -0
- mcai/site.py +497 -0
- mcai/snapshot.py +176 -0
- mcai/telemetry.py +162 -0
- mcai/templates/config/corpus.json +78 -0
- mcai/templates/config/curated.json +500 -0
- mcai/templates/config/disambiguation.json +76 -0
- mcai/templates/config/llm.json +9 -0
- mcai/templates/config/overrides.json +19 -0
- mcai/text.py +61 -0
- mcai/workspace.py +97 -0
- mcai_knowledge-0.2.0.dist-info/METADATA +606 -0
- mcai_knowledge-0.2.0.dist-info/RECORD +39 -0
- mcai_knowledge-0.2.0.dist-info/WHEEL +4 -0
- mcai_knowledge-0.2.0.dist-info/entry_points.txt +3 -0
- mcai_knowledge-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcai_knowledge-0.2.0.dist-info/licenses/LICENSE-CONTENT +24 -0
mcai/corpus.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""语料装载:把 docx / pdf / txt 统一成带章节路径的段落块。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
from . import pdftext
|
|
11
|
+
from .text import clean
|
|
12
|
+
|
|
13
|
+
TERM_LINE = re.compile(
|
|
14
|
+
r"^\s*([^\s::,。;、!?()()《》\"']{2,16})\s*(?:[::]|——|—|=|=)\s*(.{8,})$"
|
|
15
|
+
)
|
|
16
|
+
_HEADING_STYLE = re.compile(r"^(Heading|标题)\s*(\d)?", re.I)
|
|
17
|
+
_HEADING_NOISE = re.compile(r"[。!?;,,;]$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Block:
|
|
22
|
+
doc_id: str
|
|
23
|
+
doc_title: str
|
|
24
|
+
order: int
|
|
25
|
+
text: str
|
|
26
|
+
style: str = "Normal"
|
|
27
|
+
section: tuple[str, ...] = ()
|
|
28
|
+
location: str = ""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def is_term_line(self) -> bool:
|
|
32
|
+
return bool(TERM_LINE.match(self.text))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Document:
|
|
37
|
+
doc_id: str
|
|
38
|
+
title: str
|
|
39
|
+
path: str
|
|
40
|
+
kind: str
|
|
41
|
+
priority: int
|
|
42
|
+
blocks: list[Block] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def text(self) -> str:
|
|
46
|
+
return "\n".join(b.text for b in self.blocks)
|
|
47
|
+
|
|
48
|
+
def sections(self) -> list[str]:
|
|
49
|
+
seen: list[str] = []
|
|
50
|
+
for b in self.blocks:
|
|
51
|
+
for s in b.section:
|
|
52
|
+
if s not in seen:
|
|
53
|
+
seen.append(s)
|
|
54
|
+
return seen
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Corpus:
|
|
59
|
+
project: dict
|
|
60
|
+
keywords: list[str]
|
|
61
|
+
term_blacklist: list[str]
|
|
62
|
+
slugs: dict[str, str]
|
|
63
|
+
documents: list[Document]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def blocks(self) -> Iterable[Block]:
|
|
67
|
+
for doc in self.documents:
|
|
68
|
+
yield from doc.blocks
|
|
69
|
+
|
|
70
|
+
def find(self, doc_id: str) -> Document | None:
|
|
71
|
+
return next((d for d in self.documents if d.doc_id == doc_id), None)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config(path: str | Path) -> dict:
|
|
75
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _docx_blocks(doc: Document, path: Path) -> list[Block]:
|
|
79
|
+
from docx import Document as Docx
|
|
80
|
+
from docx.table import Table
|
|
81
|
+
from docx.text.paragraph import Paragraph
|
|
82
|
+
|
|
83
|
+
parsed = Docx(str(path))
|
|
84
|
+
blocks: list[Block] = []
|
|
85
|
+
section: list[str] = []
|
|
86
|
+
order = 0
|
|
87
|
+
|
|
88
|
+
def push(text: str, style: str, location: str) -> None:
|
|
89
|
+
nonlocal order
|
|
90
|
+
# Word 常把多条定义用「手动换行符」塞进同一段落,这里按换行拆开
|
|
91
|
+
for part in re.split(r"[\r\n\u000b\u2028\u2029]", text or ""):
|
|
92
|
+
part = clean(part)
|
|
93
|
+
if not part:
|
|
94
|
+
continue
|
|
95
|
+
m = _HEADING_STYLE.match(style or "")
|
|
96
|
+
if m and style != "Table":
|
|
97
|
+
level = int(m.group(2) or 1)
|
|
98
|
+
del section[level - 1:]
|
|
99
|
+
section.append(part)
|
|
100
|
+
blocks.append(
|
|
101
|
+
Block(doc.doc_id, doc.title, order, part, style or "Normal", tuple(section), location)
|
|
102
|
+
)
|
|
103
|
+
order += 1
|
|
104
|
+
|
|
105
|
+
body = parsed.element.body
|
|
106
|
+
for child in body.iterchildren():
|
|
107
|
+
tag = child.tag.split("}")[-1]
|
|
108
|
+
if tag == "p":
|
|
109
|
+
para = Paragraph(child, parsed)
|
|
110
|
+
push(para.text, para.style.name if para.style is not None else "Normal", f"p{order + 1}")
|
|
111
|
+
elif tag == "tbl":
|
|
112
|
+
table = Table(child, parsed)
|
|
113
|
+
for row in table.rows:
|
|
114
|
+
cells = [clean(c.text.replace("\n", " ")) for c in row.cells]
|
|
115
|
+
push(" | ".join(c for c in cells if c), "Table", f"t{order + 1}")
|
|
116
|
+
|
|
117
|
+
_promote_section_titles(blocks)
|
|
118
|
+
return blocks
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _promote_section_titles(blocks: list[Block]) -> None:
|
|
122
|
+
"""把「元意识体系」这类无样式短标题提升为章节。
|
|
123
|
+
|
|
124
|
+
判定:Normal 样式、长度 2-20、无句末标点,且后续三个块内出现「术语:定义」。
|
|
125
|
+
两遍处理:先定位标题,再按最近标题归属,避免后一个标题被前一个吞掉。
|
|
126
|
+
"""
|
|
127
|
+
titles: dict[int, str] = {}
|
|
128
|
+
for i, block in enumerate(blocks):
|
|
129
|
+
if block.style not in ("Normal", "Normal (Web)"):
|
|
130
|
+
continue
|
|
131
|
+
text = block.text
|
|
132
|
+
if not (2 <= len(text) <= 20) or _HEADING_NOISE.search(text) or text.endswith(":"):
|
|
133
|
+
continue
|
|
134
|
+
sep = re.search(r"[::=]|——|—", text)
|
|
135
|
+
if sep and len(text[: sep.start()].strip()) < 4:
|
|
136
|
+
continue
|
|
137
|
+
if TERM_LINE.match(text) or text in ("结论表述", "结论"):
|
|
138
|
+
continue
|
|
139
|
+
if any(b.is_term_line for b in blocks[i + 1: i + 4]):
|
|
140
|
+
titles[i] = text
|
|
141
|
+
|
|
142
|
+
current: str | None = None
|
|
143
|
+
for i, block in enumerate(blocks):
|
|
144
|
+
if i in titles:
|
|
145
|
+
current = titles[i]
|
|
146
|
+
if current and len(block.section) <= 1:
|
|
147
|
+
block.section = (block.doc_title, current)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _pdf_blocks(doc: Document, path: Path) -> list[Block]:
|
|
151
|
+
blocks: list[Block] = []
|
|
152
|
+
for page_no, page in enumerate(pdftext.extract_pdf_text(str(path)), 1):
|
|
153
|
+
for line_no, line in enumerate(page.split("\n")):
|
|
154
|
+
text = clean(line)
|
|
155
|
+
if text:
|
|
156
|
+
blocks.append(
|
|
157
|
+
Block(doc.doc_id, doc.title, len(blocks), text, "PDF", (), f"p{page_no}:{line_no + 1}")
|
|
158
|
+
)
|
|
159
|
+
return blocks
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _text_blocks(doc: Document, path: Path, style: str) -> list[Block]:
|
|
163
|
+
blocks: list[Block] = []
|
|
164
|
+
section: list[str] = []
|
|
165
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
166
|
+
text = clean(raw)
|
|
167
|
+
if not text:
|
|
168
|
+
continue
|
|
169
|
+
if text.startswith("#"):
|
|
170
|
+
level = len(text) - len(text.lstrip("#"))
|
|
171
|
+
title = text.lstrip("#").strip()
|
|
172
|
+
del section[level - 1:]
|
|
173
|
+
section.append(title)
|
|
174
|
+
blocks.append(Block(doc.doc_id, doc.title, len(blocks), title, style, tuple(section), ""))
|
|
175
|
+
else:
|
|
176
|
+
blocks.append(Block(doc.doc_id, doc.title, len(blocks), text, style, tuple(section), ""))
|
|
177
|
+
return blocks
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _resolve_path(path: Path) -> Path:
|
|
181
|
+
"""文件被改名(如加了「3、」前缀)时,按文件名后缀就近找回,避免配置失效。"""
|
|
182
|
+
if path.exists():
|
|
183
|
+
return path
|
|
184
|
+
candidates = sorted(p for p in path.parent.glob(f"*{path.suffix}") if p.name.endswith(path.name))
|
|
185
|
+
return candidates[0] if candidates else path
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_document(spec: dict, base: Path | None = None) -> Document:
|
|
189
|
+
raw = Path(spec["path"]).expanduser()
|
|
190
|
+
if not raw.is_absolute() and base is not None:
|
|
191
|
+
raw = base / raw
|
|
192
|
+
path = _resolve_path(raw)
|
|
193
|
+
doc = Document(
|
|
194
|
+
doc_id=spec["id"],
|
|
195
|
+
title=spec.get("title") or path.stem,
|
|
196
|
+
path=str(path),
|
|
197
|
+
kind=spec.get("kind", "other"),
|
|
198
|
+
priority=int(spec.get("priority", 50)),
|
|
199
|
+
)
|
|
200
|
+
if not path.exists():
|
|
201
|
+
raise FileNotFoundError(f"语料缺失:{path}")
|
|
202
|
+
suffix = path.suffix.lower()
|
|
203
|
+
if suffix == ".docx":
|
|
204
|
+
doc.blocks = _docx_blocks(doc, path)
|
|
205
|
+
elif suffix == ".pdf":
|
|
206
|
+
doc.blocks = _pdf_blocks(doc, path)
|
|
207
|
+
elif suffix in (".txt", ".md"):
|
|
208
|
+
doc.blocks = _text_blocks(doc, path, "Text")
|
|
209
|
+
else:
|
|
210
|
+
raise ValueError(f"暂不支持的文件类型:{suffix}")
|
|
211
|
+
return doc
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_corpus(config_path: str | Path) -> Corpus:
|
|
215
|
+
cfg = load_config(config_path)
|
|
216
|
+
base = Path(config_path).resolve().parent.parent
|
|
217
|
+
docs = [load_document(spec, base) for spec in cfg["corpus"]]
|
|
218
|
+
return Corpus(
|
|
219
|
+
project=cfg["project"],
|
|
220
|
+
keywords=cfg.get("keywords", []),
|
|
221
|
+
term_blacklist=cfg.get("term_blacklist", []),
|
|
222
|
+
slugs=cfg.get("slugs", {}),
|
|
223
|
+
documents=docs,
|
|
224
|
+
)
|
mcai/dashboard.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""本地观测台:把自动巡检、调用统计、变更历史汇成一页静态 HTML。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .telemetry import Telemetry
|
|
10
|
+
|
|
11
|
+
CSS = """
|
|
12
|
+
body{margin:0;background:#faf8f4;color:#1c1b19;font-family:"Noto Sans SC","PingFang SC","Microsoft YaHei",system-ui,sans-serif;line-height:1.7}
|
|
13
|
+
.wrap{max-width:1000px;margin:0 auto;padding:32px 24px 64px}
|
|
14
|
+
h1{font-family:"Noto Serif SC",serif;font-size:32px;margin:0 0 6px}
|
|
15
|
+
h2{font-family:"Noto Serif SC",serif;font-size:20px;margin:36px 0 12px;padding-bottom:8px;border-bottom:1px solid #e3ddd2}
|
|
16
|
+
.muted{color:#6b665e;font-size:13px}
|
|
17
|
+
.badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;background:#e8f3e8;color:#256b2f}
|
|
18
|
+
.badge.bad{background:#fbe6e3;color:#a3311f}
|
|
19
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-top:22px}
|
|
20
|
+
.card{background:#fffdf9;border:1px solid #e3ddd2;border-radius:12px;padding:16px}
|
|
21
|
+
.card b{display:block;font-size:26px;font-family:"Noto Serif SC",serif}
|
|
22
|
+
.card span{color:#6b665e;font-size:12px}
|
|
23
|
+
table{width:100%;border-collapse:collapse;font-size:14px;background:#fffdf9;border:1px solid #e3ddd2;border-radius:10px;overflow:hidden}
|
|
24
|
+
th,td{padding:9px 12px;border-bottom:1px solid #eee7db;text-align:left;vertical-align:top}
|
|
25
|
+
th{background:#f3eee4;font-weight:600;font-size:12px;color:#6b665e}
|
|
26
|
+
tr:last-child td{border-bottom:0}
|
|
27
|
+
.bars{display:flex;align-items:flex-end;gap:6px;height:90px;margin-top:12px}
|
|
28
|
+
.bar{flex:1;background:#c9b8a4;border-radius:3px 3px 0 0;min-height:2px}
|
|
29
|
+
.barlabel{font-size:10px;color:#6b665e;text-align:center}
|
|
30
|
+
.pill{display:inline-block;padding:1px 7px;border-radius:4px;background:#efe9df;font-size:11px;color:#6b665e;margin-right:4px}
|
|
31
|
+
a{color:#8a3324;text-decoration:none}
|
|
32
|
+
a:hover{text-decoration:underline}
|
|
33
|
+
.nav a{margin-right:16px}
|
|
34
|
+
.empty{color:#a39c90;font-size:13px;padding:8px 0}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_json(path: Path, default):
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return default
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
return default
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_jsonl(path: Path, limit: int = 50) -> list[dict]:
|
|
48
|
+
if not path.exists():
|
|
49
|
+
return []
|
|
50
|
+
rows = []
|
|
51
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
52
|
+
line = line.strip()
|
|
53
|
+
if not line:
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
rows.append(json.loads(line))
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
continue
|
|
59
|
+
return rows[-limit:]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _bars(values: dict[str, int]) -> str:
|
|
63
|
+
if not values or not any(values.values()):
|
|
64
|
+
return '<div class="empty">暂无调用记录</div>'
|
|
65
|
+
peak = max(values.values()) or 1
|
|
66
|
+
out = ['<div class="bars">']
|
|
67
|
+
for day, count in values.items():
|
|
68
|
+
height = max(2, int(78 * count / peak))
|
|
69
|
+
out.append(f'<div class="bar" style="height:{height}px" title="{day}: {count}"></div>')
|
|
70
|
+
out.append("</div><div style=\"display:flex;gap:6px\">")
|
|
71
|
+
for day in values:
|
|
72
|
+
out.append(f'<div class="barlabel" style="flex:1">{day[5:]}</div>')
|
|
73
|
+
out.append("</div>")
|
|
74
|
+
return "".join(out)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def render(root: str | Path) -> Path:
|
|
78
|
+
root = Path(root)
|
|
79
|
+
data = root / "data"
|
|
80
|
+
dist = root / "dist"
|
|
81
|
+
dist.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
kb_meta = _read_json(data / "kb.json", {}).get("meta", {})
|
|
84
|
+
excluded = ((kb_meta.get("stats") or {}).get("policy") or {}).get("excluded_entities") or []
|
|
85
|
+
|
|
86
|
+
def redact(text: str) -> str:
|
|
87
|
+
for e in excluded:
|
|
88
|
+
text = text.replace(e, "(作者)")
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
status = _read_json(data / "status.json", {})
|
|
92
|
+
quality = _read_json(data / "quality.json", {})
|
|
93
|
+
selftest = _read_json(data / "selftest.json", {})
|
|
94
|
+
history = _read_jsonl(data / "autopilot.jsonl", 200)
|
|
95
|
+
changelog = _read_jsonl(data / "changelog.jsonl", 30)
|
|
96
|
+
tele = Telemetry(data / "usage.jsonl").stats()
|
|
97
|
+
|
|
98
|
+
ok = bool(status.get("ok"))
|
|
99
|
+
badge = f'<span class="badge{"" if ok else " bad"}">{"健康" if ok else "异常"}</span>'
|
|
100
|
+
|
|
101
|
+
cards = [
|
|
102
|
+
("概念", status.get("concepts", "-")),
|
|
103
|
+
("问答", status.get("faqs", "-")),
|
|
104
|
+
("关系", status.get("relations", "-")),
|
|
105
|
+
("引证", status.get("evidence", "-")),
|
|
106
|
+
("质量错误", (quality.get("counts") or {}).get("error", "-")),
|
|
107
|
+
("质量警告", (quality.get("counts") or {}).get("warn", "-")),
|
|
108
|
+
("自测通过", f"{selftest.get('passed', '-')}/{selftest.get('total', '-')}"),
|
|
109
|
+
("业务问答", tele.get("business", 0)),
|
|
110
|
+
("命中率", f"{round((tele.get('hit_rate') or 0) * 100)}%"),
|
|
111
|
+
("技能调用", (tele.get("by_client_kind") or {}).get("skill", 0)),
|
|
112
|
+
("命令行调用", (tele.get("by_client_kind") or {}).get("shell", 0)),
|
|
113
|
+
("外部调用", (tele.get("by_origin") or {}).get("external", 0)),
|
|
114
|
+
]
|
|
115
|
+
cards_html = "".join(
|
|
116
|
+
f'<div class="card"><b>{html.escape(str(v))}</b><span>{html.escape(k)}</span></div>'
|
|
117
|
+
for k, v in cards
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# 巡检时间线
|
|
121
|
+
rows = []
|
|
122
|
+
for item in reversed(history[-20:]):
|
|
123
|
+
rebuilt = "是" if item.get("rebuilt") else "否"
|
|
124
|
+
q = item.get("quality") or {}
|
|
125
|
+
t = item.get("selftest") or {}
|
|
126
|
+
rows.append(
|
|
127
|
+
"<tr>"
|
|
128
|
+
f"<td>{html.escape(str(item.get('checked_at', '')))}</td>"
|
|
129
|
+
f"<td>{'✅' if item.get('ok') else '❌'}</td>"
|
|
130
|
+
f"<td>{rebuilt}</td>"
|
|
131
|
+
f"<td>{q.get('errors', '-')}/{q.get('warnings', '-')}</td>"
|
|
132
|
+
f"<td>{t.get('passed', '-')}/{t.get('total', '-')}</td>"
|
|
133
|
+
f"<td>{item.get('duration_ms', '-')} ms</td>"
|
|
134
|
+
"</tr>"
|
|
135
|
+
)
|
|
136
|
+
timeline = (
|
|
137
|
+
"<table><tr><th>时间</th><th>健康</th><th>重建</th><th>质量 错/警</th>"
|
|
138
|
+
"<th>自测</th><th>耗时</th></tr>" + "".join(rows) + "</table>"
|
|
139
|
+
if rows else '<div class="empty">还没有巡检记录。运行一次 mcai autopilot --once。</div>'
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# 变更日志
|
|
143
|
+
change_rows = []
|
|
144
|
+
for entry in reversed(changelog[-10:]):
|
|
145
|
+
parts = []
|
|
146
|
+
if entry.get("added"):
|
|
147
|
+
parts.append("+ " + "、".join(entry["added"][:5]))
|
|
148
|
+
if entry.get("removed"):
|
|
149
|
+
parts.append("− " + "、".join(entry["removed"][:5]))
|
|
150
|
+
if entry.get("changed"):
|
|
151
|
+
parts.append("~ " + "、".join(entry["changed"][:5]))
|
|
152
|
+
change_rows.append(
|
|
153
|
+
f"<tr><td>{html.escape(str(entry.get('ts', '')))}</td>"
|
|
154
|
+
f"<td>{html.escape(';'.join(parts) or '(无术语变化)')}</td></tr>"
|
|
155
|
+
)
|
|
156
|
+
changelog_html = (
|
|
157
|
+
"<table><tr><th>时间</th><th>变化</th></tr>" + "".join(change_rows) + "</table>"
|
|
158
|
+
if change_rows else '<div class="empty">还没有术语变更记录。</div>'
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# 质量明细
|
|
162
|
+
issues = (quality.get("errors") or []) + (quality.get("warnings") or [])
|
|
163
|
+
issue_rows = "".join(
|
|
164
|
+
f"<tr><td>{html.escape(i.get('level', ''))}</td>"
|
|
165
|
+
f"<td>{html.escape(i.get('code', ''))}</td>"
|
|
166
|
+
f"<td>{html.escape(i.get('detail', ''))}</td></tr>"
|
|
167
|
+
for i in issues[:20]
|
|
168
|
+
)
|
|
169
|
+
issues_html = (
|
|
170
|
+
"<table><tr><th>级别</th><th>代码</th><th>说明</th></tr>" + issue_rows + "</table>"
|
|
171
|
+
if issue_rows else '<div class="empty">本轮没有错误或警告。提示项只在 quality.json 里。</div>'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
top_questions = "".join(
|
|
175
|
+
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>".format(
|
|
176
|
+
html.escape(redact(q.get("question", ""))),
|
|
177
|
+
q.get("count", 0),
|
|
178
|
+
html.escape("、".join(c.get("client", "") for c in q.get("clients", [])) or "(未记录)")
|
|
179
|
+
+ (" <b>外部</b>" if q.get("external") else ""),
|
|
180
|
+
)
|
|
181
|
+
for q in tele.get("top_questions", [])[:10]
|
|
182
|
+
) or '<tr><td colspan="3" class="empty">暂无业务问答</td></tr>'
|
|
183
|
+
|
|
184
|
+
html_doc = f"""<!DOCTYPE html>
|
|
185
|
+
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
186
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
187
|
+
<title>本地观测台 · 元意识知识库</title>
|
|
188
|
+
<style>{CSS}</style></head>
|
|
189
|
+
<body><div class="wrap">
|
|
190
|
+
<div class="nav muted"><a href="/index.html">知识库</a><a href="/dashboard.html">观测台</a>
|
|
191
|
+
<a href="/dataset/index.html">数据集</a><a href="/guide.md">接入指南</a></div>
|
|
192
|
+
<h1>本地观测台 {badge}</h1>
|
|
193
|
+
<div class="muted">最近一次检查:{html.escape(str(status.get('checked_at', '尚未运行')))} ·
|
|
194
|
+
服务:{'在线' if status.get('server_ok') else '离线'} ·
|
|
195
|
+
本轮重建:{'是' if status.get('rebuilt') else '否'}</div>
|
|
196
|
+
|
|
197
|
+
<div class="cards">{cards_html}</div>
|
|
198
|
+
|
|
199
|
+
<h2>调用趋势(近 14 天)</h2>
|
|
200
|
+
{_bars(tele.get('by_day', {}))}
|
|
201
|
+
<div class="muted" style="margin-top:10px">
|
|
202
|
+
健康探测 {tele.get('probes', 0)} 次 ·
|
|
203
|
+
本机 {(tele.get('by_origin') or {}).get('local', 0)} ·
|
|
204
|
+
外部 {(tele.get('by_origin') or {}).get('external', 0)} ·
|
|
205
|
+
未标记 {(tele.get('by_origin') or {}).get('unknown', 0)}<br>
|
|
206
|
+
渠道:{html.escape(str(tele.get('by_channel', {})))} ·
|
|
207
|
+
类型:{html.escape(str(tele.get('by_type', {})))}
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<h2>巡检时间线</h2>
|
|
211
|
+
{timeline}
|
|
212
|
+
|
|
213
|
+
<h2>质量明细</h2>
|
|
214
|
+
{issues_html}
|
|
215
|
+
|
|
216
|
+
<h2>术语变更</h2>
|
|
217
|
+
{changelog_html}
|
|
218
|
+
|
|
219
|
+
<h2>高频问题(仅业务问答,不含健康探测)</h2>
|
|
220
|
+
<table><tr><th>问题</th><th>次数</th><th>来源</th></tr>{top_questions}</table>
|
|
221
|
+
|
|
222
|
+
<p class="muted" style="margin-top:40px">
|
|
223
|
+
本页由自动驾驶在每次检查后重新生成 · {datetime.now().astimezone().isoformat(timespec='seconds')}
|
|
224
|
+
</p>
|
|
225
|
+
</div></body></html>"""
|
|
226
|
+
|
|
227
|
+
out = dist / "dashboard.html"
|
|
228
|
+
out.write_text(html_doc, encoding="utf-8")
|
|
229
|
+
return out
|
mcai/dataset.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""数据集导出:JSONL / CSV / 知识图谱 / 数据集卡片。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .extract import KnowledgeBase
|
|
9
|
+
|
|
10
|
+
SYSTEM_PROMPT = (
|
|
11
|
+
"你是「元意识学说」的知识助手。回答时严格使用学说自身的术语体系:"
|
|
12
|
+
"元意识、元态能量、观测创造实在、现象时空、序生、序生智能体、元识、元知、元灵、元子。"
|
|
13
|
+
"不把学说混同于量子神秘主义或既有哲学流派,不使用未在术语集中定义的表述。"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _write_jsonl(path: Path, rows: list[dict]) -> None:
|
|
18
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
19
|
+
for row in rows:
|
|
20
|
+
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def export(kb: KnowledgeBase, out_dir: str | Path) -> dict:
|
|
24
|
+
out = Path(out_dir)
|
|
25
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
concepts = [
|
|
28
|
+
{
|
|
29
|
+
"id": c.id,
|
|
30
|
+
"term": c.term,
|
|
31
|
+
"slug": c.slug,
|
|
32
|
+
"en": c.en,
|
|
33
|
+
"aliases": c.aliases,
|
|
34
|
+
"category": c.category,
|
|
35
|
+
"kind": c.kind,
|
|
36
|
+
"parent": c.parent,
|
|
37
|
+
"definition": c.definition,
|
|
38
|
+
"short_definition": c.short_definition,
|
|
39
|
+
"source_documents": sorted({s.doc_title for s in c.sources}),
|
|
40
|
+
"evidence": [
|
|
41
|
+
{"doc": e["doc_title"], "section": e["section"], "quote": e["quote"], "lang": e["lang"]}
|
|
42
|
+
for e in c.evidence[:4]
|
|
43
|
+
],
|
|
44
|
+
"status": c.status,
|
|
45
|
+
}
|
|
46
|
+
for c in kb.concepts
|
|
47
|
+
]
|
|
48
|
+
_write_jsonl(out / "concepts.jsonl", concepts)
|
|
49
|
+
|
|
50
|
+
evidence_rows = [
|
|
51
|
+
{
|
|
52
|
+
"term": c.term,
|
|
53
|
+
"slug": c.slug,
|
|
54
|
+
"category": c.category,
|
|
55
|
+
"doc": e["doc_title"],
|
|
56
|
+
"section": e["section"],
|
|
57
|
+
"quote": e["quote"],
|
|
58
|
+
"lang": e["lang"],
|
|
59
|
+
}
|
|
60
|
+
for c in kb.concepts
|
|
61
|
+
for e in c.evidence
|
|
62
|
+
]
|
|
63
|
+
_write_jsonl(out / "evidence.jsonl", evidence_rows)
|
|
64
|
+
|
|
65
|
+
faqs = [
|
|
66
|
+
{
|
|
67
|
+
"id": f.id,
|
|
68
|
+
"question": f.question,
|
|
69
|
+
"answer": f.answer,
|
|
70
|
+
"short_answer": f.short_answer,
|
|
71
|
+
"kind": f.kind,
|
|
72
|
+
"related": f.related,
|
|
73
|
+
"source_terms": f.source_terms,
|
|
74
|
+
"confidence": f.confidence,
|
|
75
|
+
"campaign": f.campaign,
|
|
76
|
+
}
|
|
77
|
+
for f in kb.faqs
|
|
78
|
+
]
|
|
79
|
+
_write_jsonl(out / "faq.jsonl", faqs)
|
|
80
|
+
|
|
81
|
+
sft = [
|
|
82
|
+
{
|
|
83
|
+
"messages": [
|
|
84
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
85
|
+
{"role": "user", "content": f.question},
|
|
86
|
+
{"role": "assistant", "content": f.answer},
|
|
87
|
+
],
|
|
88
|
+
"metadata": {"faq_id": f.id, "kind": f.kind, "confidence": f.confidence},
|
|
89
|
+
}
|
|
90
|
+
for f in kb.faqs
|
|
91
|
+
]
|
|
92
|
+
_write_jsonl(out / "sft.jsonl", sft)
|
|
93
|
+
|
|
94
|
+
with (out / "concepts.csv").open("w", encoding="utf-8-sig", newline="") as fh:
|
|
95
|
+
writer = csv.writer(fh)
|
|
96
|
+
writer.writerow(["id", "term", "en", "aliases", "category", "definition", "status", "sources"])
|
|
97
|
+
for c in kb.concepts:
|
|
98
|
+
writer.writerow([
|
|
99
|
+
c.id, c.term, c.en, "|".join(c.aliases), c.category,
|
|
100
|
+
c.definition, c.status, "|".join(sorted({s.doc_title for s in c.sources})),
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
nodes = [
|
|
104
|
+
{"id": c.slug, "label": c.term, "category": c.category, "definition": c.short_definition}
|
|
105
|
+
for c in kb.concepts
|
|
106
|
+
]
|
|
107
|
+
edges = [
|
|
108
|
+
{"source": c.slug, "target": r["target_id"], "type": r["type"], "evidence": r.get("evidence", "")}
|
|
109
|
+
for c in kb.concepts
|
|
110
|
+
for r in c.relations
|
|
111
|
+
if r.get("target_id")
|
|
112
|
+
]
|
|
113
|
+
graph = {"nodes": nodes, "edges": edges, "meta": {"nodes": len(nodes), "edges": len(edges)}}
|
|
114
|
+
(out / "knowledge-graph.json").write_text(
|
|
115
|
+
json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
(out / "dataset-card.md").write_text(_card(kb), encoding="utf-8")
|
|
119
|
+
return {
|
|
120
|
+
"dir": str(out),
|
|
121
|
+
"files": sorted(p.name for p in out.iterdir() if p.is_file()),
|
|
122
|
+
"concepts": len(concepts),
|
|
123
|
+
"faqs": len(faqs),
|
|
124
|
+
"evidence": len(evidence_rows),
|
|
125
|
+
"edges": len(edges),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _card(kb: KnowledgeBase) -> str:
|
|
130
|
+
project = kb.project
|
|
131
|
+
cats = "\n".join(f"- {name}:{count} 条" for name, count in kb.stats["categories"].items())
|
|
132
|
+
docs = "\n".join(
|
|
133
|
+
f"- 《{d['title']}》({d['kind']},{d['blocks']} 段)" for d in kb.documents
|
|
134
|
+
)
|
|
135
|
+
return f"""# {project['brand']}学说结构化语料库 v1.0
|
|
136
|
+
|
|
137
|
+
## 数据集简介
|
|
138
|
+
|
|
139
|
+
本数据集把《{project['brand']}》学说的多份原始文本(术语词源、学说极简版、本体论、元子社会等)
|
|
140
|
+
转换为机器可直接引用的结构化知识资产,用于大模型知识对齐、RAG 检索、知识图谱构建与
|
|
141
|
+
「知识资产数字永生」工程。
|
|
142
|
+
|
|
143
|
+
- 概念定义:{kb.stats['concepts']} 条
|
|
144
|
+
- 标准问答:{kb.stats['faqs']} 组
|
|
145
|
+
- 概念关系:{kb.stats['relations']} 条
|
|
146
|
+
- 文献引证:{kb.stats.get('evidence', 0)} 条
|
|
147
|
+
- 语料来源:{len(kb.documents)} 份文档 / {kb.stats['blocks']} 个段落 / 约 {kb.stats['characters']:,} 字
|
|
148
|
+
- 构建时间:{kb.built_at}
|
|
149
|
+
- 许可:{project['license']}
|
|
150
|
+
|
|
151
|
+
## 目录结构
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
concepts.jsonl 概念定义(一条一行)
|
|
155
|
+
faq.jsonl 标准问答
|
|
156
|
+
evidence.jsonl 文献引证(概念在各著作中的论述原句)
|
|
157
|
+
sft.jsonl 对话微调格式(system/user/assistant)
|
|
158
|
+
concepts.csv 表格版,便于运营审阅
|
|
159
|
+
knowledge-graph.json 节点-边知识图谱
|
|
160
|
+
dataset-card.md 本文件
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## 字段说明
|
|
164
|
+
|
|
165
|
+
### concepts.jsonl
|
|
166
|
+
|
|
167
|
+
| 字段 | 含义 |
|
|
168
|
+
|---|---|
|
|
169
|
+
| id / slug | 唯一标识与 URL 片段 |
|
|
170
|
+
| term | 规范术语名 |
|
|
171
|
+
| en | 英文对应词 |
|
|
172
|
+
| aliases | 别名、曾用名、英文缩写 |
|
|
173
|
+
| category | 所属体系(元意识体系 / 本体论模型 / 序生体系 / 元子社会) |
|
|
174
|
+
| definition | 主分支定义(已去重、去冲突) |
|
|
175
|
+
| short_definition | 一句话定义 |
|
|
176
|
+
| source_documents | 定义来源文档 |
|
|
177
|
+
| status | ok / conflict / thin,表示质量状态 |
|
|
178
|
+
|
|
179
|
+
### sft.jsonl
|
|
180
|
+
|
|
181
|
+
直接用于指令微调,system 提示词已锁定术语边界,避免模型把学说混同于其他体系。
|
|
182
|
+
|
|
183
|
+
## 概念分布
|
|
184
|
+
|
|
185
|
+
{cats}
|
|
186
|
+
|
|
187
|
+
## 语料来源
|
|
188
|
+
|
|
189
|
+
{docs}
|
|
190
|
+
|
|
191
|
+
## 使用建议
|
|
192
|
+
|
|
193
|
+
1. **RAG**:以 `concepts.jsonl` 建立向量库,检索后把 `definition` 作为上下文注入。
|
|
194
|
+
2. **微调**:`sft.jsonl` 可直接用于 LoRA/全参微调;建议与通用语料按 1:4 混合以防灾难性遗忘。
|
|
195
|
+
3. **知识图谱**:`knowledge-graph.json` 可导入 Neo4j / NetworkX,用于多跳问答与概念推荐。
|
|
196
|
+
4. **一致性约束**:`status=conflict` 的条目须先人工裁定后再进入训练集。
|
|
197
|
+
|
|
198
|
+
## 引用
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
{project['org']}. {project['brand']}学说结构化语料库 v1.0. {kb.built_at[:10]}.
|
|
202
|
+
```
|
|
203
|
+
"""
|