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/extract.py
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
"""概念抽取:从语料段落中抽取「术语:定义」,合并多来源、检测冲突、构建关系与 FAQ。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import text as T
|
|
11
|
+
from .corpus import Block, Corpus
|
|
12
|
+
|
|
13
|
+
TERM_RE = re.compile(r"^\s*([^::,。;、!?()()《》\"']{2,16})\s*(?:[::]|——|—|=|=)\s*(.+)$")
|
|
14
|
+
_BAD_TERM = re.compile(r"^(\d+|[A-Za-z]{1,2}|[一二三四五六七八九十]+[、..]?)")
|
|
15
|
+
_ALIAS_PATTERNS = [
|
|
16
|
+
re.compile(r"[,,]\s*(?:又称|也称|亦称|简称|俗称|又名)([\u4e00-\u9fa5A-Za-z]{2,12})"),
|
|
17
|
+
re.compile(r"(?:也就是|即|即所谓)([\u4e00-\u9fa5]{2,10})"),
|
|
18
|
+
re.compile(r"叫([\u4e00-\u9fa5]{2,8})(?:的|,|。|$|()"),
|
|
19
|
+
]
|
|
20
|
+
_STOP_PREFIX = ("核心逻辑", "行动指南", "目标效果", "示例", "例子", "应对", "注", "说明",
|
|
21
|
+
"结论", "总结", "背景", "风险", "策略", "方法", "步骤", "定义", "概念")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Source:
|
|
26
|
+
doc_id: str
|
|
27
|
+
doc_title: str
|
|
28
|
+
section: str
|
|
29
|
+
location: str
|
|
30
|
+
quote: str
|
|
31
|
+
priority: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Variant:
|
|
36
|
+
doc_id: str
|
|
37
|
+
doc_title: str
|
|
38
|
+
definition: str
|
|
39
|
+
similarity: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Concept:
|
|
44
|
+
term: str
|
|
45
|
+
slug: str
|
|
46
|
+
definition: str
|
|
47
|
+
category: str = "其他"
|
|
48
|
+
en: str = ""
|
|
49
|
+
aliases: list[str] = field(default_factory=list)
|
|
50
|
+
sources: list[Source] = field(default_factory=list)
|
|
51
|
+
variants: list[Variant] = field(default_factory=list)
|
|
52
|
+
relations: list[dict] = field(default_factory=list)
|
|
53
|
+
evidence: list[dict] = field(default_factory=list)
|
|
54
|
+
kind: str = "core" # core | derived
|
|
55
|
+
parent: str = "" # 衍生术语的父概念
|
|
56
|
+
parent_id: str = ""
|
|
57
|
+
disambiguation: str = "" # 阶段性消歧句(不进入 definition)
|
|
58
|
+
tags: list[str] = field(default_factory=list)
|
|
59
|
+
status: str = "ok"
|
|
60
|
+
issues: list[str] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def id(self) -> str:
|
|
64
|
+
return self.slug
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def short_definition(self) -> str:
|
|
68
|
+
parts = T.sentences(self.definition)
|
|
69
|
+
if not parts:
|
|
70
|
+
return T.clean(self.definition)[:90]
|
|
71
|
+
acc = parts[0]
|
|
72
|
+
i = 1
|
|
73
|
+
# 避免以分号、逗号结尾的碎片开头,最多拼接三段
|
|
74
|
+
while i < len(parts) and i < 3 and (len(acc) < 24 or acc.endswith((";", ";", ",", ","))):
|
|
75
|
+
acc += parts[i]
|
|
76
|
+
i += 1
|
|
77
|
+
if len(acc) > 100:
|
|
78
|
+
acc = acc[:100].rstrip(",,、;") + "……"
|
|
79
|
+
return acc
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict:
|
|
82
|
+
return {
|
|
83
|
+
"id": self.id,
|
|
84
|
+
"term": self.term,
|
|
85
|
+
"slug": self.slug,
|
|
86
|
+
"en": self.en,
|
|
87
|
+
"category": self.category,
|
|
88
|
+
"definition": self.definition,
|
|
89
|
+
"short_definition": self.short_definition,
|
|
90
|
+
"aliases": self.aliases,
|
|
91
|
+
"tags": self.tags,
|
|
92
|
+
"kind": self.kind,
|
|
93
|
+
"parent": self.parent,
|
|
94
|
+
"parent_id": self.parent_id,
|
|
95
|
+
"disambiguation": self.disambiguation,
|
|
96
|
+
"status": self.status,
|
|
97
|
+
"issues": self.issues,
|
|
98
|
+
"sources": [s.__dict__ for s in self.sources],
|
|
99
|
+
"variants": [v.__dict__ for v in self.variants],
|
|
100
|
+
"evidence": self.evidence,
|
|
101
|
+
"relations": self.relations,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class Faq:
|
|
107
|
+
id: str
|
|
108
|
+
question: str
|
|
109
|
+
answer: str
|
|
110
|
+
kind: str
|
|
111
|
+
related: list[str] = field(default_factory=list)
|
|
112
|
+
source_terms: list[str] = field(default_factory=list)
|
|
113
|
+
confidence: str = "medium"
|
|
114
|
+
campaign: bool = False
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def short_answer(self) -> str:
|
|
118
|
+
return T.first_sentence(self.answer, 70)
|
|
119
|
+
|
|
120
|
+
def to_dict(self) -> dict:
|
|
121
|
+
return {
|
|
122
|
+
"id": self.id,
|
|
123
|
+
"question": self.question,
|
|
124
|
+
"short_answer": self.short_answer,
|
|
125
|
+
"answer": self.answer,
|
|
126
|
+
"kind": self.kind,
|
|
127
|
+
"campaign": self.campaign,
|
|
128
|
+
"related": self.related,
|
|
129
|
+
"source_terms": self.source_terms,
|
|
130
|
+
"confidence": self.confidence,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class KnowledgeBase:
|
|
136
|
+
project: dict
|
|
137
|
+
concepts: list[Concept]
|
|
138
|
+
faqs: list[Faq]
|
|
139
|
+
documents: list[dict]
|
|
140
|
+
stats: dict
|
|
141
|
+
built_at: str
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def by_term(self) -> dict[str, Concept]:
|
|
145
|
+
return {c.term: c for c in self.concepts}
|
|
146
|
+
|
|
147
|
+
def to_dict(self) -> dict:
|
|
148
|
+
return {
|
|
149
|
+
"meta": {
|
|
150
|
+
"project": self.project,
|
|
151
|
+
"built_at": self.built_at,
|
|
152
|
+
"documents": self.documents,
|
|
153
|
+
"stats": self.stats,
|
|
154
|
+
},
|
|
155
|
+
"concepts": [c.to_dict() for c in self.concepts],
|
|
156
|
+
"faqs": [f.to_dict() for f in self.faqs],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def save(self, path: str | Path) -> None:
|
|
160
|
+
path = Path(path)
|
|
161
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
path.write_text(json.dumps(self.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _reject_term(term: str, blacklist: list[str], curated: dict) -> bool:
|
|
166
|
+
term = term.strip().strip("—-- ")
|
|
167
|
+
if not (2 <= len(term) <= 16):
|
|
168
|
+
return True
|
|
169
|
+
if term in blacklist or term in curated.get("drop_terms", []):
|
|
170
|
+
return True
|
|
171
|
+
if term in curated.get("excluded_entities", []):
|
|
172
|
+
return True
|
|
173
|
+
if any(term.startswith(p) for p in _STOP_PREFIX):
|
|
174
|
+
return True
|
|
175
|
+
if term in ("表格", "目录", "引言", "注释", "参考", "文献"):
|
|
176
|
+
return True
|
|
177
|
+
if _BAD_TERM.match(term):
|
|
178
|
+
return True
|
|
179
|
+
if re.search(r"\d", term):
|
|
180
|
+
return True
|
|
181
|
+
allowed = {t for members in curated.get("categories", {}).values() for t in members}
|
|
182
|
+
if term not in allowed:
|
|
183
|
+
if "的" in term or any(k in term for k in ("可能", "是否", "我们", "及其", "具有")):
|
|
184
|
+
return True
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _collect_blocks(corpus: Corpus, blacklist: list[str], curated: dict) -> list[tuple[str, str, Block]]:
|
|
189
|
+
"""只从术语/模型类文档抽取定义;叙事与运营文档仅作为使用证据。"""
|
|
190
|
+
allowed = {"glossary", "model"}
|
|
191
|
+
alias_map = curated.get("term_aliases", {})
|
|
192
|
+
hits: list[tuple[str, str, Block]] = []
|
|
193
|
+
for doc in corpus.documents:
|
|
194
|
+
if doc.kind not in allowed:
|
|
195
|
+
continue
|
|
196
|
+
for block in doc.blocks:
|
|
197
|
+
if not block.is_term_line:
|
|
198
|
+
continue
|
|
199
|
+
m = TERM_RE.match(block.text)
|
|
200
|
+
if not m:
|
|
201
|
+
continue
|
|
202
|
+
raw_term = m.group(1).strip().strip("—-- ")
|
|
203
|
+
definition = T.clean(m.group(2))
|
|
204
|
+
if raw_term in alias_map:
|
|
205
|
+
term = alias_map[raw_term]
|
|
206
|
+
else:
|
|
207
|
+
if _reject_term(raw_term, blacklist, curated):
|
|
208
|
+
continue
|
|
209
|
+
term = raw_term
|
|
210
|
+
if any(k in term for k in ("可能", "是否", "我们", "及其", "具有")):
|
|
211
|
+
continue
|
|
212
|
+
if len(term) > 16 or len(definition) < 8:
|
|
213
|
+
continue
|
|
214
|
+
if definition.endswith("?") or definition.endswith("?"):
|
|
215
|
+
continue
|
|
216
|
+
hits.append((term, definition, block))
|
|
217
|
+
return hits
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _alias_candidates(term: str, definition: str, known_terms: set[str]) -> list[str]:
|
|
221
|
+
"""只接受「已知术语」作为别名,避免把「其他文化中的」之类误抓进来。"""
|
|
222
|
+
out: list[str] = []
|
|
223
|
+
if "与" in term and len(term) <= 14:
|
|
224
|
+
parts = [p for p in term.split("与") if len(p) >= 2]
|
|
225
|
+
if len(parts) == 2 and all(p in known_terms for p in parts):
|
|
226
|
+
out.extend(parts)
|
|
227
|
+
for pattern in _ALIAS_PATTERNS:
|
|
228
|
+
for m in pattern.finditer(definition):
|
|
229
|
+
candidate = m.group(1).strip()
|
|
230
|
+
if candidate != term and candidate in known_terms:
|
|
231
|
+
out.append(candidate)
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _resolve_slug(term: str, slugs: dict[str, str], used: set[str]) -> str:
|
|
236
|
+
base = slugs.get(term) or f"c-{T.md5_short(term)}"
|
|
237
|
+
slug, n = base, 2
|
|
238
|
+
while slug in used:
|
|
239
|
+
slug = f"{base}-{n}"
|
|
240
|
+
n += 1
|
|
241
|
+
used.add(slug)
|
|
242
|
+
return slug
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _category_for(term: str, block: Block, curated: dict) -> str:
|
|
246
|
+
for name, members in curated.get("categories", {}).items():
|
|
247
|
+
if term in members:
|
|
248
|
+
return name
|
|
249
|
+
section = block.section[-1] if block.section else ""
|
|
250
|
+
if "元子" in section:
|
|
251
|
+
return "元子社会"
|
|
252
|
+
if "本体" in section:
|
|
253
|
+
return "本体论模型"
|
|
254
|
+
if "序生" in section:
|
|
255
|
+
return "序生体系"
|
|
256
|
+
return {
|
|
257
|
+
"terms": "元意识体系",
|
|
258
|
+
"minimal": "元意识体系",
|
|
259
|
+
"noumenon": "本体论模型",
|
|
260
|
+
"metabeing": "元子社会",
|
|
261
|
+
}.get(block.doc_id, "元意识体系")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _extra_hits(corpus: Corpus, curated: dict) -> list[tuple[str, str, Block]]:
|
|
265
|
+
"""编辑层补充概念:以人工确认的定义作为主分支(优先级最高)。"""
|
|
266
|
+
hits: list[tuple[str, str, Block]] = []
|
|
267
|
+
docs = {d.doc_id: d for d in corpus.documents}
|
|
268
|
+
for item in curated.get("extra_concepts", []):
|
|
269
|
+
doc = docs.get(item.get("doc_id", ""))
|
|
270
|
+
if doc is None:
|
|
271
|
+
continue
|
|
272
|
+
block = Block(
|
|
273
|
+
doc_id=doc.doc_id,
|
|
274
|
+
doc_title=doc.title,
|
|
275
|
+
order=-1,
|
|
276
|
+
text=item["quote"],
|
|
277
|
+
style="Curated",
|
|
278
|
+
section=(doc.title, item.get("category", "")),
|
|
279
|
+
location="curated",
|
|
280
|
+
)
|
|
281
|
+
hits.append((item["term"], item["definition"], block))
|
|
282
|
+
return hits
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def build(corpus: Corpus, curated: dict, overrides: dict | None = None,
|
|
286
|
+
disambig=None) -> KnowledgeBase:
|
|
287
|
+
overrides = overrides or {}
|
|
288
|
+
hits = _collect_blocks(corpus, corpus.term_blacklist, curated)
|
|
289
|
+
hits.extend(_extra_hits(corpus, curated))
|
|
290
|
+
hits.extend(_override_hits(corpus, overrides))
|
|
291
|
+
|
|
292
|
+
grouped: dict[str, list[tuple[str, Block]]] = {}
|
|
293
|
+
for term, definition, block in hits:
|
|
294
|
+
grouped.setdefault(term, []).append((definition, block))
|
|
295
|
+
|
|
296
|
+
used_slugs: set[str] = set()
|
|
297
|
+
known_terms = set(grouped) | {
|
|
298
|
+
t for members in curated.get("categories", {}).values() for t in members
|
|
299
|
+
}
|
|
300
|
+
overridden = {
|
|
301
|
+
term for term, patch in overrides.items()
|
|
302
|
+
if isinstance(patch, dict) and (patch.get("definition") or "").strip()
|
|
303
|
+
}
|
|
304
|
+
extra_meta = {
|
|
305
|
+
item["term"]: item for item in curated.get("extra_concepts", [])
|
|
306
|
+
if isinstance(item, dict) and item.get("term")
|
|
307
|
+
}
|
|
308
|
+
concepts: list[Concept] = []
|
|
309
|
+
for term, entries in grouped.items():
|
|
310
|
+
entries.sort(key=lambda e: -_priority(e[1]))
|
|
311
|
+
canonical_def, canonical_block = entries[0]
|
|
312
|
+
concept = Concept(
|
|
313
|
+
term=term,
|
|
314
|
+
slug=_resolve_slug(term, corpus.slugs, used_slugs),
|
|
315
|
+
definition=canonical_def,
|
|
316
|
+
category=_category_for(term, canonical_block, curated),
|
|
317
|
+
en=curated.get("english", {}).get(term, ""),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
for definition, block in entries:
|
|
321
|
+
concept.sources.append(
|
|
322
|
+
Source(
|
|
323
|
+
doc_id=block.doc_id,
|
|
324
|
+
doc_title=block.doc_title,
|
|
325
|
+
section=block.section[-1] if block.section else "",
|
|
326
|
+
location=block.location,
|
|
327
|
+
quote=definition,
|
|
328
|
+
priority=_priority(block),
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
for definition, block in entries[1:]:
|
|
333
|
+
sim = T.similarity(canonical_def, definition)
|
|
334
|
+
shorter, longer = sorted((canonical_def, definition), key=len)
|
|
335
|
+
# 一条是另一条的前缀/节选时不算冲突(常见于 Word 手动换行被截断)
|
|
336
|
+
if T.normalize(shorter) in T.normalize(longer) or T.coverage(shorter, longer) >= 0.75:
|
|
337
|
+
continue
|
|
338
|
+
length_ratio = min(len(definition), len(canonical_def)) / max(len(definition), len(canonical_def))
|
|
339
|
+
if sim < 0.6 and length_ratio < 0.8 and len(definition) >= 24:
|
|
340
|
+
concept.variants.append(
|
|
341
|
+
Variant(block.doc_id, block.doc_title, definition, round(sim, 3))
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
canonical_terms = set(grouped)
|
|
345
|
+
aliases = list(curated.get("aliases", {}).get(term, []))
|
|
346
|
+
for definition, _ in entries:
|
|
347
|
+
for candidate in _alias_candidates(term, definition, known_terms):
|
|
348
|
+
# 别名不能是另一个概念的正名(否则会互相抢占)
|
|
349
|
+
if candidate in canonical_terms and candidate != term:
|
|
350
|
+
continue
|
|
351
|
+
if candidate not in aliases:
|
|
352
|
+
aliases.append(candidate)
|
|
353
|
+
concept.aliases = [a for a in aliases if a and a != term]
|
|
354
|
+
|
|
355
|
+
patch = overrides.get(term) if isinstance(overrides.get(term), dict) else {}
|
|
356
|
+
meta = extra_meta.get(term) or {}
|
|
357
|
+
if meta.get("kind") == "derived":
|
|
358
|
+
concept.kind = "derived"
|
|
359
|
+
concept.parent = meta.get("parent", "")
|
|
360
|
+
if patch:
|
|
361
|
+
if patch.get("en"):
|
|
362
|
+
concept.en = patch["en"]
|
|
363
|
+
if patch.get("category"):
|
|
364
|
+
concept.category = patch["category"]
|
|
365
|
+
for extra_alias in patch.get("aliases", []) or []:
|
|
366
|
+
if extra_alias not in concept.aliases:
|
|
367
|
+
concept.aliases.append(extra_alias)
|
|
368
|
+
|
|
369
|
+
if term in overridden:
|
|
370
|
+
concept.variants = []
|
|
371
|
+
concept.issues = [i for i in concept.issues if "差异较大的定义" not in i]
|
|
372
|
+
|
|
373
|
+
if concept.variants:
|
|
374
|
+
concept.status = "conflict"
|
|
375
|
+
concept.issues.append(f"存在 {len(concept.variants)} 个差异较大的定义版本,需人工裁定")
|
|
376
|
+
if len(concept.definition) < 12:
|
|
377
|
+
concept.status = "thin" if concept.status == "ok" else concept.status
|
|
378
|
+
concept.issues.append("定义过短,可能不足以被 AI 直接引用")
|
|
379
|
+
concepts.append(concept)
|
|
380
|
+
|
|
381
|
+
concepts.sort(key=lambda c: (c.category, -len(c.definition)))
|
|
382
|
+
by_term_final = {c.term: c for c in concepts}
|
|
383
|
+
for concept in concepts:
|
|
384
|
+
if concept.parent and concept.parent in by_term_final:
|
|
385
|
+
concept.parent_id = by_term_final[concept.parent].id
|
|
386
|
+
|
|
387
|
+
_link_relations(concepts, curated)
|
|
388
|
+
evidence_count = _attach_evidence(concepts, corpus, curated)
|
|
389
|
+
if disambig is not None and getattr(disambig, "active", False):
|
|
390
|
+
for concept in concepts:
|
|
391
|
+
note = disambig.note_for(concept.term)
|
|
392
|
+
if note:
|
|
393
|
+
concept.disambiguation = note
|
|
394
|
+
faqs = _build_faq(concepts, curated, disambig)
|
|
395
|
+
|
|
396
|
+
documents = [
|
|
397
|
+
{"id": d.doc_id, "title": d.title, "path": d.path, "kind": d.kind,
|
|
398
|
+
"priority": d.priority, "blocks": len(d.blocks), "sections": len(d.sections())}
|
|
399
|
+
for d in corpus.documents
|
|
400
|
+
]
|
|
401
|
+
stats = _stats(concepts, faqs, curated, corpus, evidence_count, disambig)
|
|
402
|
+
|
|
403
|
+
return KnowledgeBase(
|
|
404
|
+
project=corpus.project,
|
|
405
|
+
concepts=concepts,
|
|
406
|
+
faqs=faqs,
|
|
407
|
+
documents=documents,
|
|
408
|
+
stats=stats,
|
|
409
|
+
built_at=datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _override_hits(corpus: Corpus, overrides: dict) -> list[tuple[str, str, Block]]:
|
|
414
|
+
"""人工审校层:overrides.json 中裁定过的定义优先级最高(高于原文与编辑层)。"""
|
|
415
|
+
hits: list[tuple[str, str, Block]] = []
|
|
416
|
+
docs = {d.doc_id: d for d in corpus.documents}
|
|
417
|
+
doc = docs.get("minimal") or next(iter(docs.values()), None)
|
|
418
|
+
if doc is None:
|
|
419
|
+
return hits
|
|
420
|
+
for term, patch in overrides.items():
|
|
421
|
+
if term.startswith("_") or not isinstance(patch, dict):
|
|
422
|
+
continue
|
|
423
|
+
definition = (patch.get("definition") or "").strip()
|
|
424
|
+
if not definition:
|
|
425
|
+
continue
|
|
426
|
+
block = Block(
|
|
427
|
+
doc_id=doc.doc_id,
|
|
428
|
+
doc_title=doc.title,
|
|
429
|
+
order=-2,
|
|
430
|
+
text=definition,
|
|
431
|
+
style="Override",
|
|
432
|
+
section=(doc.title, patch.get("category", "")),
|
|
433
|
+
location="override",
|
|
434
|
+
)
|
|
435
|
+
hits.append((term, definition, block))
|
|
436
|
+
return hits
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _priority(block: Block) -> int:
|
|
440
|
+
if block.style == "Override":
|
|
441
|
+
return 300
|
|
442
|
+
if block.style == "Curated":
|
|
443
|
+
return 200
|
|
444
|
+
base = {"terms": 100, "minimal": 95, "noumenon": 90, "metabeing": 90,
|
|
445
|
+
"progenerative": 70, "manual": 60}.get(block.doc_id, 50)
|
|
446
|
+
if block.doc_title and "术语" in block.doc_title:
|
|
447
|
+
base += 10
|
|
448
|
+
return base
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _link_relations(concepts: list[Concept], curated: dict) -> None:
|
|
452
|
+
by_term = {c.term: c for c in concepts}
|
|
453
|
+
index: list[tuple[str, Concept]] = []
|
|
454
|
+
for c in concepts:
|
|
455
|
+
index.append((c.term, c))
|
|
456
|
+
for a in c.aliases:
|
|
457
|
+
if a not in by_term:
|
|
458
|
+
index.append((a, c))
|
|
459
|
+
index.sort(key=lambda x: -len(x[0]))
|
|
460
|
+
|
|
461
|
+
seen: set[tuple[str, str, str]] = set()
|
|
462
|
+
|
|
463
|
+
def add(src: Concept, dst: Concept, kind: str, evidence: str = "") -> None:
|
|
464
|
+
if src is dst:
|
|
465
|
+
return
|
|
466
|
+
key = (src.term, dst.term, kind)
|
|
467
|
+
if key in seen:
|
|
468
|
+
return
|
|
469
|
+
seen.add(key)
|
|
470
|
+
src.relations.append({"target": dst.term, "target_id": dst.id, "type": kind, "evidence": evidence})
|
|
471
|
+
|
|
472
|
+
for src, dst, kind in curated.get("core_loop", []):
|
|
473
|
+
if src in by_term and dst in by_term:
|
|
474
|
+
add(by_term[src], by_term[dst], kind)
|
|
475
|
+
|
|
476
|
+
for item in curated.get("extra_concepts", []):
|
|
477
|
+
if not isinstance(item, dict) or item.get("kind") != "derived":
|
|
478
|
+
continue
|
|
479
|
+
term, parent = item.get("term"), item.get("parent")
|
|
480
|
+
if term in by_term and parent in by_term:
|
|
481
|
+
add(by_term[term], by_term[parent], "衍生自")
|
|
482
|
+
|
|
483
|
+
for concept in concepts:
|
|
484
|
+
body = concept.definition
|
|
485
|
+
for name, target in index:
|
|
486
|
+
if target is concept or name == concept.term:
|
|
487
|
+
continue
|
|
488
|
+
if len(name) < 2 or name not in body:
|
|
489
|
+
continue
|
|
490
|
+
if any(name in a for a in concept.aliases):
|
|
491
|
+
continue
|
|
492
|
+
snippet = next((s for s in T.sentences(body) if name in s), "")
|
|
493
|
+
add(concept, target, "提及", snippet[:120])
|
|
494
|
+
|
|
495
|
+
for c in concepts:
|
|
496
|
+
# 同一目标已有强关系(核心闭环/衍生自)时,不再重复保留「提及」
|
|
497
|
+
strong = {r["target"] for r in c.relations if r["type"] != "提及"}
|
|
498
|
+
c.relations = [r for r in c.relations if r["type"] != "提及" or r["target"] not in strong]
|
|
499
|
+
c.relations.sort(key=lambda r: (r["type"] != "提及", r["target"]))
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _evidence_score(sentence: str, term: str, definition: str) -> int:
|
|
503
|
+
"""给候选引证打分:越像「关于该概念的论述」分越高,标题/摘要类扣分。"""
|
|
504
|
+
score = 0
|
|
505
|
+
pos = sentence.find(term)
|
|
506
|
+
if 0 <= pos <= 12:
|
|
507
|
+
score += 2
|
|
508
|
+
if any(k in sentence for k in ("是指", "指的是", "称为", "所谓", "意味着", "也就是说", "本质")):
|
|
509
|
+
score += 2
|
|
510
|
+
sim = T.similarity(sentence, definition)
|
|
511
|
+
if 0.2 <= sim <= 0.8:
|
|
512
|
+
score += 1
|
|
513
|
+
if sentence[:4] in ("本文首先", "本文认为", "摘要:", "关键词") or "本文" in sentence[:6]:
|
|
514
|
+
score -= 3
|
|
515
|
+
if sentence[:2] in ("首先", "其次", "最后", "综上", "由此") or sentence[:3] in ("最后讨", "本文梳"):
|
|
516
|
+
score -= 2
|
|
517
|
+
if any(k in sentence for k in ("讨论", "梳理", "辨析", "本文", "文章")):
|
|
518
|
+
score -= 1
|
|
519
|
+
if sentence.endswith(":") or sentence.endswith(":"):
|
|
520
|
+
score -= 2
|
|
521
|
+
# 标题行通常不以句末标点结尾
|
|
522
|
+
if sentence[-1:] not in "。!?…”\"'":
|
|
523
|
+
score -= 2
|
|
524
|
+
if len(sentence) > 160:
|
|
525
|
+
score -= 1
|
|
526
|
+
return score
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _split_sentences(text: str) -> list[str]:
|
|
530
|
+
"""中文按中文标点切句;英文段落按 . ! ? 切句(否则整段会被当成一句而超长丢弃)。"""
|
|
531
|
+
cjk = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
|
|
532
|
+
if cjk <= max(2, len(text) * 0.05):
|
|
533
|
+
parts = re.split(r"(?<=[.!?])\s+", text)
|
|
534
|
+
return [p.strip() for p in parts if p.strip()]
|
|
535
|
+
return T.sentences(text)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _match_english(lower: str, names: list[str]) -> str | None:
|
|
539
|
+
"""英文术语按词边界匹配,避免 Consciousness 命中 Meta-Consciousness。"""
|
|
540
|
+
for name in names:
|
|
541
|
+
pattern = r"(?<![a-z-])" + re.escape(name) + r"(?![a-z-])"
|
|
542
|
+
if re.search(pattern, lower):
|
|
543
|
+
return name
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _attach_evidence(concepts: list[Concept], corpus: Corpus, curated: dict,
|
|
548
|
+
per_doc: int = 3, total: int = 8) -> int:
|
|
549
|
+
"""从「引证源」文档中摘取论述片段,只补充出处,不改动定义。"""
|
|
550
|
+
docs = [d for d in corpus.documents if d.kind == "evidence"]
|
|
551
|
+
if not docs:
|
|
552
|
+
return 0
|
|
553
|
+
titles = {d.title for d in docs}
|
|
554
|
+
excluded = curated.get("excluded_entities", [])
|
|
555
|
+
count = 0
|
|
556
|
+
for concept in concepts:
|
|
557
|
+
names = [n for n in [concept.term, *concept.aliases] if len(n) >= 2]
|
|
558
|
+
english = [concept.en.lower()] if concept.en else []
|
|
559
|
+
for doc in docs:
|
|
560
|
+
candidates: list[tuple[int, dict]] = []
|
|
561
|
+
seen: set[str] = set()
|
|
562
|
+
for block in doc.blocks:
|
|
563
|
+
for sentence in _split_sentences(block.text):
|
|
564
|
+
if not (16 <= len(sentence) <= 220):
|
|
565
|
+
continue
|
|
566
|
+
if sentence.strip() in titles:
|
|
567
|
+
continue
|
|
568
|
+
if any(e in sentence for e in excluded):
|
|
569
|
+
continue
|
|
570
|
+
lower = sentence.lower()
|
|
571
|
+
matched = next((n for n in names if n in sentence), None)
|
|
572
|
+
lang = "zh"
|
|
573
|
+
if not matched:
|
|
574
|
+
matched = _match_english(lower, english)
|
|
575
|
+
lang = "en"
|
|
576
|
+
if not matched:
|
|
577
|
+
continue
|
|
578
|
+
key = T.normalize(sentence)[:60]
|
|
579
|
+
if key in seen:
|
|
580
|
+
continue
|
|
581
|
+
if T.similarity(sentence, concept.definition) > 0.9:
|
|
582
|
+
continue
|
|
583
|
+
seen.add(key)
|
|
584
|
+
score = _evidence_score(sentence, matched, concept.definition)
|
|
585
|
+
if score < 0:
|
|
586
|
+
continue
|
|
587
|
+
candidates.append((score, {
|
|
588
|
+
"doc_id": doc.doc_id,
|
|
589
|
+
"doc_title": doc.title,
|
|
590
|
+
"section": block.section[-1] if block.section else "",
|
|
591
|
+
"location": block.location,
|
|
592
|
+
"quote": sentence,
|
|
593
|
+
"lang": lang,
|
|
594
|
+
"score": score,
|
|
595
|
+
}))
|
|
596
|
+
candidates.sort(key=lambda item: -item[0])
|
|
597
|
+
picked = [item for _, item in candidates[:per_doc]]
|
|
598
|
+
concept.evidence.extend(picked)
|
|
599
|
+
count += len(picked)
|
|
600
|
+
zh = sorted([e for e in concept.evidence if e["lang"] == "zh"], key=lambda e: -e["score"])
|
|
601
|
+
en = sorted([e for e in concept.evidence if e["lang"] == "en"], key=lambda e: -e["score"])
|
|
602
|
+
concept.evidence = zh[: max(0, total - min(len(en), 2))] + en[:2]
|
|
603
|
+
return count
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _build_faq(concepts: list[Concept], curated: dict, disambig=None) -> list[Faq]:
|
|
607
|
+
by_term = {c.term: c for c in concepts}
|
|
608
|
+
faqs: list[Faq] = []
|
|
609
|
+
used: set[str] = set()
|
|
610
|
+
|
|
611
|
+
for item in curated.get("faq_seed", []):
|
|
612
|
+
q = item["question"]
|
|
613
|
+
used.add(T.normalize(q))
|
|
614
|
+
faqs.append(
|
|
615
|
+
Faq(
|
|
616
|
+
id=f"faq-{T.md5_short(q)}",
|
|
617
|
+
question=q,
|
|
618
|
+
answer=item["answer"],
|
|
619
|
+
kind="curated",
|
|
620
|
+
related=[by_term[t].id for t in item.get("related", []) if t in by_term],
|
|
621
|
+
source_terms=[t for t in item.get("related", []) if t in by_term],
|
|
622
|
+
confidence="high",
|
|
623
|
+
)
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
for a, b, answer in curated.get("contrast_pairs", []):
|
|
627
|
+
if a not in by_term or b not in by_term:
|
|
628
|
+
continue
|
|
629
|
+
q = f"{a}和{b}有什么区别?"
|
|
630
|
+
if T.normalize(q) in used:
|
|
631
|
+
continue
|
|
632
|
+
used.add(T.normalize(q))
|
|
633
|
+
faqs.append(
|
|
634
|
+
Faq(
|
|
635
|
+
id=f"faq-{T.md5_short(q)}",
|
|
636
|
+
question=q,
|
|
637
|
+
answer=answer,
|
|
638
|
+
kind="contrast",
|
|
639
|
+
related=[by_term[a].id, by_term[b].id],
|
|
640
|
+
source_terms=[a, b],
|
|
641
|
+
confidence="high",
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
for concept in concepts:
|
|
646
|
+
if concept.status == "thin":
|
|
647
|
+
continue
|
|
648
|
+
q = f"什么是{concept.term}?"
|
|
649
|
+
if T.normalize(q) in used:
|
|
650
|
+
continue
|
|
651
|
+
used.add(T.normalize(q))
|
|
652
|
+
answer = concept.definition
|
|
653
|
+
if concept.variants:
|
|
654
|
+
answer += "(注:该术语在不同文本中存在表述差异,此为主分支定义。)"
|
|
655
|
+
faqs.append(
|
|
656
|
+
Faq(
|
|
657
|
+
id=f"faq-{T.md5_short(q)}",
|
|
658
|
+
question=q,
|
|
659
|
+
answer=answer,
|
|
660
|
+
kind="definition",
|
|
661
|
+
related=[concept.id],
|
|
662
|
+
source_terms=[concept.term],
|
|
663
|
+
confidence="high" if concept.status == "ok" else "medium",
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
# 把文献引证扩成「如何理解 X」问答:定义打底 + 一条原典论述作支撑
|
|
668
|
+
for concept in concepts:
|
|
669
|
+
if not concept.evidence:
|
|
670
|
+
continue
|
|
671
|
+
best = max(concept.evidence, key=lambda e: e["score"])
|
|
672
|
+
if best["score"] < 2:
|
|
673
|
+
continue
|
|
674
|
+
q = f"如何理解{concept.term}?"
|
|
675
|
+
if T.normalize(q) in used:
|
|
676
|
+
continue
|
|
677
|
+
used.add(T.normalize(q))
|
|
678
|
+
answer = concept.short_definition
|
|
679
|
+
quote = best["quote"]
|
|
680
|
+
if T.similarity(quote, answer) < 0.6:
|
|
681
|
+
answer = f"{answer}《{best['doc_title']}》中进一步指出:「{quote}」"
|
|
682
|
+
faqs.append(
|
|
683
|
+
Faq(
|
|
684
|
+
id=f"faq-{T.md5_short(q)}",
|
|
685
|
+
question=q,
|
|
686
|
+
answer=answer,
|
|
687
|
+
kind="evidence",
|
|
688
|
+
related=[concept.id],
|
|
689
|
+
source_terms=[concept.term],
|
|
690
|
+
confidence="high" if best["score"] >= 3 else "medium",
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if disambig is not None and getattr(disambig, "active", False):
|
|
695
|
+
for item in disambig.faq_entries():
|
|
696
|
+
q = item["question"]
|
|
697
|
+
if T.normalize(q) in used:
|
|
698
|
+
continue
|
|
699
|
+
used.add(T.normalize(q))
|
|
700
|
+
target = by_term.get(item["term"])
|
|
701
|
+
faqs.append(
|
|
702
|
+
Faq(
|
|
703
|
+
id=f"faq-{T.md5_short(q)}",
|
|
704
|
+
question=q,
|
|
705
|
+
answer=item["answer"],
|
|
706
|
+
kind="disambiguation",
|
|
707
|
+
related=[target.id] if target else [],
|
|
708
|
+
source_terms=[item["term"]],
|
|
709
|
+
confidence="high",
|
|
710
|
+
campaign=True,
|
|
711
|
+
)
|
|
712
|
+
)
|
|
713
|
+
return faqs
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _stats(concepts: list[Concept], faqs: list[Faq], curated: dict, corpus: Corpus,
|
|
717
|
+
evidence_count: int = 0, disambig=None) -> dict:
|
|
718
|
+
by_category: dict[str, int] = {}
|
|
719
|
+
by_status: dict[str, int] = {}
|
|
720
|
+
concept_kind = {"core": 0, "derived": 0}
|
|
721
|
+
for c in concepts:
|
|
722
|
+
by_category[c.category] = by_category.get(c.category, 0) + 1
|
|
723
|
+
by_status[c.status] = by_status.get(c.status, 0) + 1
|
|
724
|
+
concept_kind[c.kind] = concept_kind.get(c.kind, 0) + 1
|
|
725
|
+
|
|
726
|
+
expected = [t for members in curated.get("categories", {}).values() for t in members]
|
|
727
|
+
missing = [t for t in expected if t not in {c.term for c in concepts}]
|
|
728
|
+
graph_edges = sum(len(c.relations) for c in concepts)
|
|
729
|
+
by_kind: dict[str, int] = {}
|
|
730
|
+
for d in corpus.documents:
|
|
731
|
+
by_kind[d.kind] = by_kind.get(d.kind, 0) + 1
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
"concepts": len(concepts),
|
|
735
|
+
"faqs": len(faqs),
|
|
736
|
+
"relations": graph_edges,
|
|
737
|
+
"evidence": evidence_count,
|
|
738
|
+
"categories": by_category,
|
|
739
|
+
"status": by_status,
|
|
740
|
+
"policy": {
|
|
741
|
+
"excluded_entities": curated.get("excluded_entities", []),
|
|
742
|
+
"answer": curated.get("policy_answer", ""),
|
|
743
|
+
"principle": curated.get("policy_principle", ""),
|
|
744
|
+
},
|
|
745
|
+
"concept_kind": concept_kind,
|
|
746
|
+
"campaign_faqs": sum(1 for f in faqs if f.campaign),
|
|
747
|
+
"disambiguation": (disambig.status if disambig is not None else {"enabled": False, "items": 0}),
|
|
748
|
+
"by_kind": by_kind,
|
|
749
|
+
"coverage": {
|
|
750
|
+
"expected": len(expected),
|
|
751
|
+
"covered": len(expected) - len(missing),
|
|
752
|
+
"missing": missing,
|
|
753
|
+
},
|
|
754
|
+
"documents": len(corpus.documents),
|
|
755
|
+
"blocks": sum(len(d.blocks) for d in corpus.documents),
|
|
756
|
+
"characters": sum(len(b.text) for d in corpus.documents for b in d.blocks),
|
|
757
|
+
}
|