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/pipeline.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""构建流水线:语料 → 知识库 → 静态站 → 数据集。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import dashboard, dataset, site
|
|
8
|
+
from .corpus import load_corpus, load_config
|
|
9
|
+
from .disambig import load as load_disambig
|
|
10
|
+
from .extract import build
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def rebuild(root: str | Path) -> dict:
|
|
14
|
+
root = Path(root).resolve()
|
|
15
|
+
corpus = load_corpus(root / "config" / "corpus.json")
|
|
16
|
+
curated = load_config(root / "config" / "curated.json")
|
|
17
|
+
overrides_path = root / "config" / "overrides.json"
|
|
18
|
+
overrides = load_config(overrides_path) if overrides_path.exists() else {}
|
|
19
|
+
disambig = load_disambig(root / "config" / "disambiguation.json")
|
|
20
|
+
kb = build(corpus, curated, overrides, disambig)
|
|
21
|
+
|
|
22
|
+
kb.save(root / "data" / "kb.json")
|
|
23
|
+
pages = site.build_site(kb, root / "dist")
|
|
24
|
+
files = dataset.export(kb, root / "dist" / "dataset")
|
|
25
|
+
try:
|
|
26
|
+
dashboard.render(root)
|
|
27
|
+
except Exception: # noqa: BLE001 - 看板失败不影响构建
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
"ok": True,
|
|
32
|
+
"concepts": kb.stats["concepts"],
|
|
33
|
+
"faqs": kb.stats["faqs"],
|
|
34
|
+
"relations": kb.stats["relations"],
|
|
35
|
+
"pages": pages["pages"],
|
|
36
|
+
"dataset": files,
|
|
37
|
+
"built_at": kb.built_at,
|
|
38
|
+
"conflicts": [c.term for c in kb.concepts if c.status == "conflict"],
|
|
39
|
+
"coverage_missing": kb.stats["coverage"]["missing"],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_kb(root: str | Path) -> dict:
|
|
44
|
+
path = Path(root) / "data" / "kb.json"
|
|
45
|
+
if not path.exists():
|
|
46
|
+
rebuild(root)
|
|
47
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
mcai/probe.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""抽检器:测量「模型是否自发知道这套学说」,以及知识注入能带来多少提升。
|
|
2
|
+
|
|
3
|
+
两类条件对照:
|
|
4
|
+
- baseline:不给任何资料,看模型从训练记忆里能说出多少;
|
|
5
|
+
- grounded:注入知识库检索到的定义与引证,看回答质量变化。
|
|
6
|
+
|
|
7
|
+
评分不依赖人工:术语命中 + 与标准答案的字二元组覆盖率。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .knowledge import Knowledge
|
|
17
|
+
from .llm import LLM
|
|
18
|
+
|
|
19
|
+
CORE_ORDER = [
|
|
20
|
+
"元意识", "观测创造实在", "现象时空", "元态能量", "序生智能体",
|
|
21
|
+
"元识", "元知", "元灵", "元子", "本体", "序生", "序生体",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
BASELINE_SYSTEM = "你是一位乐于助人的通用助手。请直接、简洁地回答用户问题。"
|
|
25
|
+
GROUNDED_SYSTEM = (
|
|
26
|
+
"你是「元意识学说」的知识助手。只能依据下面提供的资料回答,不要引入资料之外的理论;"
|
|
27
|
+
"若资料不足,请明确说明「资料未涉及」。回答控制在 300 字以内。\n\n资料:\n{context}"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _bigrams(text: str) -> set[str]:
|
|
32
|
+
s = re.sub(r"[\s,。、;:!?\u201c\u201d()()《》【】\[\]…—\-]", "", text or "")
|
|
33
|
+
return {s[i: i + 2] for i in range(len(s) - 1)} if len(s) > 1 else {s}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_questions(kb: Knowledge, limit: int = 12) -> list[dict]:
|
|
37
|
+
out: list[dict] = []
|
|
38
|
+
seen: set[str] = set()
|
|
39
|
+
|
|
40
|
+
def add(kind: str, question: str, terms: list[str], reference: str) -> None:
|
|
41
|
+
key = re.sub(r"\s", "", question)
|
|
42
|
+
if key in seen or not reference:
|
|
43
|
+
return
|
|
44
|
+
seen.add(key)
|
|
45
|
+
out.append({
|
|
46
|
+
"id": f"q{len(out) + 1}",
|
|
47
|
+
"kind": kind,
|
|
48
|
+
"question": question,
|
|
49
|
+
"terms": [t for t in terms if t],
|
|
50
|
+
"reference": reference,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
faqs_by_kind: dict[str, list[dict]] = {}
|
|
54
|
+
for f in kb.faqs:
|
|
55
|
+
faqs_by_kind.setdefault(f["kind"], []).append(f)
|
|
56
|
+
|
|
57
|
+
definition_faqs = {next((t for t in f.get("source_terms") or []), ""): f
|
|
58
|
+
for f in faqs_by_kind.get("definition", [])}
|
|
59
|
+
for term in CORE_ORDER:
|
|
60
|
+
f = definition_faqs.get(term)
|
|
61
|
+
if f:
|
|
62
|
+
add("definition", f["question"], [term], f["answer"])
|
|
63
|
+
|
|
64
|
+
for f in faqs_by_kind.get("contrast", [])[:3]:
|
|
65
|
+
add("contrast", f["question"], f.get("source_terms") or [], f["answer"])
|
|
66
|
+
|
|
67
|
+
for f in faqs_by_kind.get("curated", [])[:3]:
|
|
68
|
+
add("curated", f["question"], f.get("source_terms") or ["元意识"], f["answer"])
|
|
69
|
+
|
|
70
|
+
relation_added = 0
|
|
71
|
+
for concept in kb.concepts:
|
|
72
|
+
if relation_added >= 2:
|
|
73
|
+
break
|
|
74
|
+
for rel in concept.get("relations") or []:
|
|
75
|
+
if rel["type"] == "提及" or not rel.get("target_id"):
|
|
76
|
+
continue
|
|
77
|
+
question = f"{concept['term']}和{rel['target']}是什么关系?"
|
|
78
|
+
reference = kb.answer(question).get("answer", "")
|
|
79
|
+
if reference and "没有直接记录" not in reference:
|
|
80
|
+
add("relation", question, [concept["term"], rel["target"]], reference)
|
|
81
|
+
relation_added += 1
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
return out[:limit]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_context(kb: Knowledge, question: str) -> str:
|
|
88
|
+
answer = kb.answer(question)
|
|
89
|
+
parts: list[str] = []
|
|
90
|
+
if answer.get("type") == "concept":
|
|
91
|
+
concept = kb.by_term.get(answer.get("term", ""))
|
|
92
|
+
if concept:
|
|
93
|
+
parts.append(f"【{concept['term']}】{concept['definition']}")
|
|
94
|
+
for ev in (concept.get("evidence") or [])[:2]:
|
|
95
|
+
parts.append(f"原文({ev['doc_title']}):「{ev['quote']}」")
|
|
96
|
+
related = "、".join(r["target"] for r in (concept.get("relations") or [])[:5])
|
|
97
|
+
if related:
|
|
98
|
+
parts.append(f"相关概念:{related}")
|
|
99
|
+
elif answer.get("type") == "faq":
|
|
100
|
+
parts.append(answer["answer"])
|
|
101
|
+
for item in answer.get("related") or []:
|
|
102
|
+
concept = kb.by_slug.get(item.get("slug", ""))
|
|
103
|
+
if concept:
|
|
104
|
+
parts.append(f"【{concept['term']}】{concept['definition']}")
|
|
105
|
+
elif answer.get("type") == "relation":
|
|
106
|
+
parts.append(answer["answer"])
|
|
107
|
+
else:
|
|
108
|
+
for hit in kb.search(question, limit=2):
|
|
109
|
+
concept = kb.by_slug.get(hit["slug"])
|
|
110
|
+
if concept:
|
|
111
|
+
parts.append(f"【{concept['term']}】{concept['definition']}")
|
|
112
|
+
return "\n".join(parts)[:1800]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def score(answer: str, reference: str, terms: list[str],
|
|
116
|
+
kb_terms: list[str] | None = None) -> dict:
|
|
117
|
+
"""对齐率看「是否说出了标准答案的实质」,术语密度看「是否在用这套词汇」。"""
|
|
118
|
+
mention = any(t in answer for t in terms)
|
|
119
|
+
ref_grams = _bigrams(reference)
|
|
120
|
+
ans_grams = _bigrams(answer)
|
|
121
|
+
coverage = len(ref_grams & ans_grams) / max(1, len(ref_grams))
|
|
122
|
+
kb_hits = sum(1 for t in (kb_terms or []) if t in answer)
|
|
123
|
+
return {
|
|
124
|
+
"mention": mention,
|
|
125
|
+
"coverage": round(coverage, 3),
|
|
126
|
+
"aligned": coverage >= 0.35,
|
|
127
|
+
"kb_terms": kb_hits,
|
|
128
|
+
"chars": len(answer),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def run(llm: LLM, kb: Knowledge, questions: list[dict], progress=None) -> dict:
|
|
133
|
+
kb_terms = [c["term"] for c in kb.concepts]
|
|
134
|
+
results = []
|
|
135
|
+
for i, q in enumerate(questions, 1):
|
|
136
|
+
context = build_context(kb, q["question"])
|
|
137
|
+
record = {"question": q, "context": context, "answers": {}}
|
|
138
|
+
for condition, system in (("baseline", BASELINE_SYSTEM),
|
|
139
|
+
("grounded", GROUNDED_SYSTEM.format(context=context))):
|
|
140
|
+
messages = [{"role": "system", "content": system},
|
|
141
|
+
{"role": "user", "content": q["question"]}]
|
|
142
|
+
text = llm.chat(messages, temperature=0.0, max_tokens=400)
|
|
143
|
+
record["answers"][condition] = {
|
|
144
|
+
"text": text,
|
|
145
|
+
"score": score(text, q["reference"], q["terms"], kb_terms),
|
|
146
|
+
}
|
|
147
|
+
results.append(record)
|
|
148
|
+
if progress:
|
|
149
|
+
progress(i, len(questions), q["question"])
|
|
150
|
+
|
|
151
|
+
summary = {}
|
|
152
|
+
for condition in ("baseline", "grounded"):
|
|
153
|
+
rows = [r["answers"][condition]["score"] for r in results]
|
|
154
|
+
summary[condition] = {
|
|
155
|
+
"alignment_rate": round(sum(1 for r in rows if r["aligned"]) / len(rows), 3) if rows else 0,
|
|
156
|
+
"mention_rate": round(sum(1 for r in rows if r["mention"]) / len(rows), 3) if rows else 0,
|
|
157
|
+
"coverage_mean": round(sum(r["coverage"] for r in rows) / len(rows), 3) if rows else 0,
|
|
158
|
+
"kb_terms_mean": round(sum(r["kb_terms"] for r in rows) / len(rows), 1) if rows else 0,
|
|
159
|
+
"chars_mean": round(sum(r["chars"] for r in rows) / len(rows)) if rows else 0,
|
|
160
|
+
}
|
|
161
|
+
summary["uplift"] = {
|
|
162
|
+
"alignment_rate": round(summary["grounded"]["alignment_rate"] - summary["baseline"]["alignment_rate"], 3),
|
|
163
|
+
"coverage": round(summary["grounded"]["coverage_mean"] - summary["baseline"]["coverage_mean"], 3),
|
|
164
|
+
"kb_terms": round(summary["grounded"]["kb_terms_mean"] - summary["baseline"]["kb_terms_mean"], 1),
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
168
|
+
"model": llm.model,
|
|
169
|
+
"questions": len(results),
|
|
170
|
+
"usage": llm.stats(),
|
|
171
|
+
"summary": summary,
|
|
172
|
+
"results": results,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def write_report(result: dict, out_dir: str | Path) -> dict:
|
|
177
|
+
out = Path(out_dir)
|
|
178
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
180
|
+
json_path = out / f"probe-{stamp}.json"
|
|
181
|
+
json_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
182
|
+
|
|
183
|
+
s = result["summary"]
|
|
184
|
+
rows = []
|
|
185
|
+
for item in result["results"]:
|
|
186
|
+
q = item["question"]
|
|
187
|
+
b = item["answers"]["baseline"]["score"]
|
|
188
|
+
g = item["answers"]["grounded"]["score"]
|
|
189
|
+
rows.append(
|
|
190
|
+
f"| {q['id']} | {q['kind']} | {q['question'][:24]} | "
|
|
191
|
+
f"{'✓' if b['aligned'] else '×'} | {b['coverage']:.2f} | {b['kb_terms']} | "
|
|
192
|
+
f"{'✓' if g['aligned'] else '×'} | {g['coverage']:.2f} | {g['kb_terms']} |"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
md = f"""# 元意识 · 模型抽检报告
|
|
196
|
+
|
|
197
|
+
- 时间:{result['generated_at']}
|
|
198
|
+
- 模型:{result['model']}
|
|
199
|
+
- 题量:{result['questions']}(每题跑 baseline / grounded 两种条件)
|
|
200
|
+
- 用量:{result['usage']['calls']} 次调用,{result['usage']['prompt_tokens']} + {result['usage']['completion_tokens']} tokens
|
|
201
|
+
|
|
202
|
+
## 汇总
|
|
203
|
+
|
|
204
|
+
| 指标 | baseline(无资料) | grounded(注入知识库) | 提升 |
|
|
205
|
+
|---|---|---|---|
|
|
206
|
+
| 答案对齐率(覆盖 ≥ 0.35) | {s['baseline']['alignment_rate']:.0%} | {s['grounded']['alignment_rate']:.0%} | {s['uplift']['alignment_rate']:+.0%} |
|
|
207
|
+
| 与标准答案覆盖度 | {s['baseline']['coverage_mean']:.2f} | {s['grounded']['coverage_mean']:.2f} | {s['uplift']['coverage']:+.2f} |
|
|
208
|
+
| 平均出现学说术语数 | {s['baseline']['kb_terms_mean']:.1f} | {s['grounded']['kb_terms_mean']:.1f} | {s['uplift']['kb_terms']:+.1f} |
|
|
209
|
+
| 平均字数 | {s['baseline']['chars_mean']} | {s['grounded']['chars_mean']} | — |
|
|
210
|
+
|
|
211
|
+
## 逐题结果
|
|
212
|
+
|
|
213
|
+
| # | 类型 | 问题 | 基线对齐 | 基线覆盖 | 基线术语 | 注入对齐 | 注入覆盖 | 注入术语 |
|
|
214
|
+
|---|---|---|---|---|---|---|---|---|
|
|
215
|
+
{chr(10).join(rows)}
|
|
216
|
+
|
|
217
|
+
## 怎么读这份报告
|
|
218
|
+
|
|
219
|
+
- **baseline 对齐率**:不给任何资料时,模型回答与学说标准答案的接近程度。它近似反映“训练语料里有没有这套学说”。
|
|
220
|
+
- **grounded 对齐率**:注入知识库后的接近程度,反映“知识资产作为外部记忆”的质量上限。
|
|
221
|
+
- **两者的差(提升)**:就是这套知识资产对模型回答的实际增益,也是“被调用”价值的直接证据。
|
|
222
|
+
- **术语数**:回答中出现了多少个学说术语,反映词汇渗透度;只看提及会高估,因为模型会复述问题。
|
|
223
|
+
|
|
224
|
+
> 说明:评分基于字面覆盖,不等于事实正确;它适合看趋势与条件对比,不适合给单条回答打分。
|
|
225
|
+
"""
|
|
226
|
+
md_path = out / f"probe-{stamp}.md"
|
|
227
|
+
md_path.write_text(md, encoding="utf-8")
|
|
228
|
+
(out / "latest.md").write_text(md, encoding="utf-8")
|
|
229
|
+
(out / "latest.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
230
|
+
return {"json": str(json_path), "markdown": str(md_path), "summary": s}
|
mcai/quality.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""质量巡检:每次构建后自动检查知识库与站点的「硬问题」。
|
|
2
|
+
|
|
3
|
+
只报告,不改内容——术语与问答一律人工维护。
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from lxml import html
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _issue(code: str, level: str, detail: str) -> dict:
|
|
16
|
+
return {"code": code, "level": level, "detail": detail}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check_kb(kb: dict) -> list[dict]:
|
|
20
|
+
issues: list[dict] = []
|
|
21
|
+
concepts = kb.get("concepts", [])
|
|
22
|
+
faqs = kb.get("faqs", [])
|
|
23
|
+
if not concepts:
|
|
24
|
+
issues.append(_issue("kb.empty", "error", "知识库没有任何概念"))
|
|
25
|
+
return issues
|
|
26
|
+
|
|
27
|
+
by_slug = {c.get("slug") for c in concepts}
|
|
28
|
+
definition_map: dict[str, list[str]] = {}
|
|
29
|
+
for c in concepts:
|
|
30
|
+
term = c.get("term", "")
|
|
31
|
+
definition = (c.get("definition") or "").strip()
|
|
32
|
+
if len(definition) < 12:
|
|
33
|
+
issues.append(_issue("concept.thin", "error", f"{term} 的定义过短({len(definition)} 字)"))
|
|
34
|
+
if not c.get("sources"):
|
|
35
|
+
issues.append(_issue("concept.no_source", "error", f"{term} 没有定义来源"))
|
|
36
|
+
if c.get("status") == "conflict":
|
|
37
|
+
issues.append(_issue("concept.conflict", "error", f"{term} 存在未裁定的定义冲突"))
|
|
38
|
+
slug = c.get("slug", "")
|
|
39
|
+
if slug.startswith("c-"):
|
|
40
|
+
issues.append(_issue("concept.slug_missing", "warn", f"{term} 未配置 URL slug(回退为 {slug})"))
|
|
41
|
+
if not c.get("en"):
|
|
42
|
+
issues.append(_issue("concept.en_missing", "info", f"{term} 缺少英文名"))
|
|
43
|
+
key = " ".join(definition.split())[:80]
|
|
44
|
+
definition_map.setdefault(key, []).append(term)
|
|
45
|
+
for rel in c.get("relations") or []:
|
|
46
|
+
tid = rel.get("target_id")
|
|
47
|
+
if tid and tid not in by_slug:
|
|
48
|
+
issues.append(_issue("relation.dangling", "error",
|
|
49
|
+
f"{term} 的关系指向不存在的概念 {tid}"))
|
|
50
|
+
|
|
51
|
+
for key, terms in definition_map.items():
|
|
52
|
+
if len(terms) > 1:
|
|
53
|
+
issues.append(_issue("concept.duplicate_definition", "warn",
|
|
54
|
+
"定义完全相同的术语:" + "、".join(terms)))
|
|
55
|
+
|
|
56
|
+
seen_questions = Counter()
|
|
57
|
+
for f in faqs:
|
|
58
|
+
q = (f.get("question") or "").strip()
|
|
59
|
+
if not q or not (f.get("answer") or "").strip():
|
|
60
|
+
issues.append(_issue("faq.empty", "error", f"问答为空:{q or '(无问题)'}"))
|
|
61
|
+
seen_questions[q] += 1
|
|
62
|
+
for q, n in seen_questions.items():
|
|
63
|
+
if n > 1:
|
|
64
|
+
issues.append(_issue("faq.duplicate", "warn", f"问题重复 {n} 次:{q}"))
|
|
65
|
+
|
|
66
|
+
stats = kb.get("meta", {}).get("stats", {})
|
|
67
|
+
excluded = ((stats.get("policy") or {}).get("excluded_entities")) or []
|
|
68
|
+
if excluded:
|
|
69
|
+
for c in concepts:
|
|
70
|
+
blob = " ".join([
|
|
71
|
+
c.get("term", ""), c.get("definition", ""),
|
|
72
|
+
" ".join(s.get("quote", "") for s in c.get("sources") or []),
|
|
73
|
+
" ".join(e.get("quote", "") for e in c.get("evidence") or []),
|
|
74
|
+
])
|
|
75
|
+
if any(e in blob for e in excluded):
|
|
76
|
+
issues.append(_issue("policy.entity_leak", "error",
|
|
77
|
+
f"{c.get('term', '')} 含被排除的人物信息"))
|
|
78
|
+
for f in faqs:
|
|
79
|
+
blob = (f.get("question", "") or "") + (f.get("answer", "") or "")
|
|
80
|
+
if any(e in blob for e in excluded):
|
|
81
|
+
issues.append(_issue("policy.entity_leak", "error",
|
|
82
|
+
f"问答含被排除的人物信息:{f.get('question', '')[:20]}"))
|
|
83
|
+
|
|
84
|
+
campaign = stats.get("disambiguation") or {}
|
|
85
|
+
if not campaign.get("enabled") and any(f.get("campaign") for f in faqs):
|
|
86
|
+
issues.append(_issue("campaign.leak", "error", "消歧层已关闭,但问答里仍有 campaign 条目"))
|
|
87
|
+
missing = (stats.get("coverage") or {}).get("missing") or []
|
|
88
|
+
if missing:
|
|
89
|
+
issues.append(_issue("coverage.missing", "warn", "词表覆盖缺失:" + "、".join(missing[:10])))
|
|
90
|
+
|
|
91
|
+
return issues
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_site(dist: Path) -> list[dict]:
|
|
95
|
+
issues: list[dict] = []
|
|
96
|
+
pages = sorted(dist.rglob("*.html"))
|
|
97
|
+
if not pages:
|
|
98
|
+
return [_issue("site.empty", "error", "dist 下没有 HTML 页面")]
|
|
99
|
+
|
|
100
|
+
missing_targets: set[str] = set()
|
|
101
|
+
jsonld_errors = 0
|
|
102
|
+
for page in pages:
|
|
103
|
+
try:
|
|
104
|
+
doc = html.parse(str(page)).getroot()
|
|
105
|
+
except Exception as exc: # noqa: BLE001
|
|
106
|
+
issues.append(_issue("site.parse", "error", f"{page.name} 解析失败:{exc}"))
|
|
107
|
+
continue
|
|
108
|
+
if not doc.findtext(".//title"):
|
|
109
|
+
issues.append(_issue("site.title", "warn", f"{page.name} 缺少 title"))
|
|
110
|
+
for href in doc.xpath("//a/@href"):
|
|
111
|
+
if not href.startswith("/") or href.startswith("//"):
|
|
112
|
+
continue
|
|
113
|
+
target = dist / href.lstrip("/")
|
|
114
|
+
if href.endswith("/"):
|
|
115
|
+
target = target / "index.html"
|
|
116
|
+
if not target.exists() and href not in missing_targets:
|
|
117
|
+
missing_targets.add(href)
|
|
118
|
+
issues.append(_issue("site.dead_link", "error", f"{page.name} → {href}"))
|
|
119
|
+
for block in doc.xpath('//script[@type="application/ld+json"]/text()'):
|
|
120
|
+
try:
|
|
121
|
+
data = json.loads(block)
|
|
122
|
+
if "@context" not in data:
|
|
123
|
+
jsonld_errors += 1
|
|
124
|
+
except json.JSONDecodeError:
|
|
125
|
+
jsonld_errors += 1
|
|
126
|
+
if jsonld_errors:
|
|
127
|
+
issues.append(_issue("site.jsonld", "error", f"{jsonld_errors} 处 JSON-LD 无效"))
|
|
128
|
+
|
|
129
|
+
for name in ("index.html", "llms.txt", "sitemap.xml", "guide.md",
|
|
130
|
+
".well-known/mcp.json", ".well-known/agent.json", "openapi.json"):
|
|
131
|
+
if not (dist / name).exists():
|
|
132
|
+
issues.append(_issue("site.missing_artifact", "error", f"缺少 {name}"))
|
|
133
|
+
return issues
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def inspect(root: str | Path) -> dict:
|
|
137
|
+
root = Path(root)
|
|
138
|
+
issues: list[dict] = []
|
|
139
|
+
kb_path = root / "data" / "kb.json"
|
|
140
|
+
if not kb_path.exists():
|
|
141
|
+
issues.append(_issue("kb.missing", "error", "data/kb.json 不存在,尚未构建"))
|
|
142
|
+
else:
|
|
143
|
+
try:
|
|
144
|
+
issues.extend(check_kb(json.loads(kb_path.read_text(encoding="utf-8"))))
|
|
145
|
+
except json.JSONDecodeError as exc:
|
|
146
|
+
issues.append(_issue("kb.invalid", "error", f"kb.json 解析失败:{exc}"))
|
|
147
|
+
dist = root / "dist"
|
|
148
|
+
if dist.exists():
|
|
149
|
+
issues.extend(check_site(dist))
|
|
150
|
+
|
|
151
|
+
errors = [i for i in issues if i["level"] == "error"]
|
|
152
|
+
warnings = [i for i in issues if i["level"] == "warn"]
|
|
153
|
+
infos = [i for i in issues if i["level"] == "info"]
|
|
154
|
+
result = {
|
|
155
|
+
"ok": not errors,
|
|
156
|
+
"checked_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
157
|
+
"errors": errors,
|
|
158
|
+
"warnings": warnings,
|
|
159
|
+
"infos": infos,
|
|
160
|
+
"counts": {"error": len(errors), "warn": len(warnings), "info": len(infos)},
|
|
161
|
+
}
|
|
162
|
+
out = root / "data" / "quality.json"
|
|
163
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
165
|
+
return result
|
mcai/schema.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Schema.org / JSON-LD 生成:让每个概念、问答、数据集都成为机器可读实体。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .extract import Concept, Faq, KnowledgeBase
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def concept_url(project: dict, slug: str) -> str:
|
|
8
|
+
return f"{project['site_url'].rstrip('/')}/concepts/{slug}.html"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def organization(project: dict) -> dict:
|
|
12
|
+
return {
|
|
13
|
+
"@type": "Organization",
|
|
14
|
+
"@id": f"{project['site_url'].rstrip('/')}/#organization",
|
|
15
|
+
"name": project["org"],
|
|
16
|
+
"brand": {"@type": "Brand", "name": project["brand"]},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def defined_term(term: Concept, project: dict) -> dict:
|
|
21
|
+
data = {
|
|
22
|
+
"@context": "https://schema.org",
|
|
23
|
+
"@type": "DefinedTerm",
|
|
24
|
+
"@id": concept_url(project, term.slug) + "#term",
|
|
25
|
+
"name": term.term,
|
|
26
|
+
"description": term.definition,
|
|
27
|
+
"inDefinedTermSet": {
|
|
28
|
+
"@type": "DefinedTermSet",
|
|
29
|
+
"name": f"{project['brand']}术语集",
|
|
30
|
+
"url": f"{project['site_url'].rstrip('/')}/index.html#terms",
|
|
31
|
+
},
|
|
32
|
+
"termCode": term.slug,
|
|
33
|
+
"url": concept_url(project, term.slug),
|
|
34
|
+
}
|
|
35
|
+
if term.en:
|
|
36
|
+
data["alternateName"] = [term.en] + term.aliases
|
|
37
|
+
elif term.aliases:
|
|
38
|
+
data["alternateName"] = term.aliases
|
|
39
|
+
if term.category:
|
|
40
|
+
data["additionalType"] = term.category
|
|
41
|
+
if term.disambiguation:
|
|
42
|
+
# 阶段性消歧:schema.org 原生支持,且不污染 description
|
|
43
|
+
data["disambiguatingDescription"] = term.disambiguation
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def defined_term_set(kb: KnowledgeBase, project: dict) -> dict:
|
|
48
|
+
return {
|
|
49
|
+
"@context": "https://schema.org",
|
|
50
|
+
"@type": "DefinedTermSet",
|
|
51
|
+
"@id": f"{project['site_url'].rstrip('/')}/#terms",
|
|
52
|
+
"name": f"{project['brand']}学说术语集",
|
|
53
|
+
"description": f"{project['brand']}学说的标准化术语与定义集合,共 {len(kb.concepts)} 条。",
|
|
54
|
+
"url": f"{project['site_url'].rstrip('/')}/index.html",
|
|
55
|
+
"hasDefinedTerm": [
|
|
56
|
+
{
|
|
57
|
+
"@type": "DefinedTerm",
|
|
58
|
+
"name": c.term,
|
|
59
|
+
"url": concept_url(project, c.slug),
|
|
60
|
+
"termCode": c.slug,
|
|
61
|
+
}
|
|
62
|
+
for c in kb.concepts
|
|
63
|
+
],
|
|
64
|
+
"publisher": organization(project),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def question(faq: Faq, project: dict) -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"@type": "Question",
|
|
71
|
+
"name": faq.question,
|
|
72
|
+
"acceptedAnswer": {
|
|
73
|
+
"@type": "Answer",
|
|
74
|
+
"text": faq.answer,
|
|
75
|
+
"url": f"{project['site_url'].rstrip('/')}/faq/index.html#{faq.id}",
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def faq_page(faqs: list[Faq], project: dict, title: str = "元意识学说 · 常见问题") -> dict:
|
|
81
|
+
return {
|
|
82
|
+
"@context": "https://schema.org",
|
|
83
|
+
"@type": "FAQPage",
|
|
84
|
+
"@id": f"{project['site_url'].rstrip('/')}/faq/index.html#faq",
|
|
85
|
+
"name": title,
|
|
86
|
+
"url": f"{project['site_url'].rstrip('/')}/faq/index.html",
|
|
87
|
+
"mainEntity": [question(f, project) for f in faqs],
|
|
88
|
+
"publisher": organization(project),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def breadcrumb(items: list[tuple[str, str]]) -> dict:
|
|
93
|
+
return {
|
|
94
|
+
"@context": "https://schema.org",
|
|
95
|
+
"@type": "BreadcrumbList",
|
|
96
|
+
"itemListElement": [
|
|
97
|
+
{"@type": "ListItem", "position": i + 1, "name": name, "item": url}
|
|
98
|
+
for i, (name, url) in enumerate(items)
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def web_page(name: str, url: str, description: str, project: dict) -> dict:
|
|
104
|
+
return {
|
|
105
|
+
"@context": "https://schema.org",
|
|
106
|
+
"@type": "WebPage",
|
|
107
|
+
"@id": url + "#page",
|
|
108
|
+
"name": name,
|
|
109
|
+
"url": url,
|
|
110
|
+
"description": description,
|
|
111
|
+
"inLanguage": "zh-CN",
|
|
112
|
+
"isPartOf": {"@id": f"{project['site_url'].rstrip('/')}/#website"},
|
|
113
|
+
"publisher": organization(project),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def dataset(kb: KnowledgeBase, project: dict) -> dict:
|
|
118
|
+
base = project["site_url"].rstrip("/")
|
|
119
|
+
return {
|
|
120
|
+
"@context": "https://schema.org",
|
|
121
|
+
"@type": "Dataset",
|
|
122
|
+
"name": f"{project['brand']}学说结构化语料库 v1.0",
|
|
123
|
+
"description": (
|
|
124
|
+
f"由《{project['brand']}》学说系列文本清洗、抽取、去重与一致性校验后生成的结构化知识资产,"
|
|
125
|
+
f"包含 {len(kb.concepts)} 个术语定义、{len(kb.faqs)} 组标准问答与 {kb.stats['relations']} 条概念关系,"
|
|
126
|
+
"可用于大模型知识对齐、RAG 检索与知识图谱构建。"
|
|
127
|
+
),
|
|
128
|
+
"url": f"{base}/dataset/index.html",
|
|
129
|
+
"identifier": "mcai-kb-v1.0",
|
|
130
|
+
"version": "1.0",
|
|
131
|
+
"license": f"https://creativecommons.org/licenses/by-nc-sa/4.0/",
|
|
132
|
+
"creator": organization(project),
|
|
133
|
+
"keywords": ["元意识", "序生智能体", "AI意识", "知识图谱", "数字永生", "术语集"],
|
|
134
|
+
"inLanguage": "zh-CN",
|
|
135
|
+
"isAccessibleForFree": True,
|
|
136
|
+
"distribution": [
|
|
137
|
+
{
|
|
138
|
+
"@type": "DataDownload",
|
|
139
|
+
"name": "概念定义 JSONL",
|
|
140
|
+
"encodingFormat": "application/jsonlines",
|
|
141
|
+
"contentUrl": f"{base}/dataset/concepts.jsonl",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"@type": "DataDownload",
|
|
145
|
+
"name": "标准问答 JSONL",
|
|
146
|
+
"encodingFormat": "application/jsonlines",
|
|
147
|
+
"contentUrl": f"{base}/dataset/faq.jsonl",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"@type": "DataDownload",
|
|
151
|
+
"name": "概念定义 CSV",
|
|
152
|
+
"encodingFormat": "text/csv",
|
|
153
|
+
"contentUrl": f"{base}/dataset/concepts.csv",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"@type": "DataDownload",
|
|
157
|
+
"name": "文献引证 JSONL",
|
|
158
|
+
"encodingFormat": "application/jsonlines",
|
|
159
|
+
"contentUrl": f"{base}/dataset/evidence.jsonl",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"@type": "DataDownload",
|
|
163
|
+
"name": "概念图谱 JSON",
|
|
164
|
+
"encodingFormat": "application/json",
|
|
165
|
+
"contentUrl": f"{base}/dataset/knowledge-graph.json",
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
}
|