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/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
+ }