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/knowledge.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""检索与问答内核:供 HTTP API 与 MCP 共用。
|
|
2
|
+
|
|
3
|
+
不调用大模型,全部基于词表、别名、引证与问题相似度的确定性检索,
|
|
4
|
+
保证回答可追溯、可复现。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .text import clean, normalize
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _bigrams(text: str) -> set[str]:
|
|
16
|
+
s = normalize(text)
|
|
17
|
+
return {s[i: i + 2] for i in range(len(s) - 1)} if len(s) > 1 else {s}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
21
|
+
if not a or not b:
|
|
22
|
+
return 0.0
|
|
23
|
+
return len(a & b) / len(a | b)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Knowledge:
|
|
27
|
+
"""只读知识库视图,线程内可复用。"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, payload: dict):
|
|
30
|
+
self.payload = payload
|
|
31
|
+
self.meta = payload.get("meta", {})
|
|
32
|
+
self.concepts: list[dict] = payload.get("concepts", [])
|
|
33
|
+
self.faqs: list[dict] = payload.get("faqs", [])
|
|
34
|
+
self.by_slug = {c["slug"]: c for c in self.concepts}
|
|
35
|
+
self.by_term = {c["term"]: c for c in self.concepts}
|
|
36
|
+
self._alias: dict[str, dict] = {}
|
|
37
|
+
# 先登记所有正名,再用别名补位(别名不得覆盖正名)
|
|
38
|
+
for c in self.concepts:
|
|
39
|
+
self._alias[c["term"]] = c
|
|
40
|
+
self._alias[c["slug"]] = c
|
|
41
|
+
if c.get("en"):
|
|
42
|
+
self._alias[c["en"].lower()] = c
|
|
43
|
+
for c in self.concepts:
|
|
44
|
+
for a in c.get("aliases") or []:
|
|
45
|
+
self._alias.setdefault(a, c)
|
|
46
|
+
self._faq_grams = [(f, _bigrams(f["question"])) for f in self.faqs]
|
|
47
|
+
self._concept_grams = {
|
|
48
|
+
c["slug"]: (_bigrams(c["term"]), _bigrams(c.get("definition", "")))
|
|
49
|
+
for c in self.concepts
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# ---------- lookup ----------
|
|
53
|
+
def resolve(self, term: str) -> dict | None:
|
|
54
|
+
if not term:
|
|
55
|
+
return None
|
|
56
|
+
term = term.strip()
|
|
57
|
+
if term in self._alias:
|
|
58
|
+
return self._alias[term]
|
|
59
|
+
if term.lower() in self._alias:
|
|
60
|
+
return self._alias[term.lower()]
|
|
61
|
+
for key, concept in self._alias.items():
|
|
62
|
+
if len(key) >= 2 and key in term:
|
|
63
|
+
return concept
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def search(self, query: str, limit: int = 10, category: str | None = None) -> list[dict]:
|
|
67
|
+
q = clean(query)
|
|
68
|
+
if not q:
|
|
69
|
+
return []
|
|
70
|
+
ql = q.lower()
|
|
71
|
+
qg = _bigrams(q)
|
|
72
|
+
scored: list[tuple[float, dict]] = []
|
|
73
|
+
for c in self.concepts:
|
|
74
|
+
if category and c.get("category") != category:
|
|
75
|
+
continue
|
|
76
|
+
score = 0.0
|
|
77
|
+
if ql == c["term"].lower():
|
|
78
|
+
score += 100
|
|
79
|
+
elif ql == (c.get("en") or "").lower() and c.get("en"):
|
|
80
|
+
score += 95
|
|
81
|
+
elif any(ql == a.lower() for a in c.get("aliases") or []):
|
|
82
|
+
score += 90
|
|
83
|
+
elif ql in c["term"].lower():
|
|
84
|
+
score += 60
|
|
85
|
+
elif any(ql in a.lower() for a in c.get("aliases") or []):
|
|
86
|
+
score += 55
|
|
87
|
+
body = c.get("definition", "")
|
|
88
|
+
if ql in body:
|
|
89
|
+
score += 30
|
|
90
|
+
term_grams, def_grams = self._concept_grams[c["slug"]]
|
|
91
|
+
overlap = _jaccard(qg, term_grams)
|
|
92
|
+
if overlap:
|
|
93
|
+
score += 40 * overlap
|
|
94
|
+
score += 8 * _jaccard(qg, def_grams)
|
|
95
|
+
if score > 0:
|
|
96
|
+
scored.append((score, c))
|
|
97
|
+
scored.sort(key=lambda item: -item[0])
|
|
98
|
+
return [
|
|
99
|
+
{
|
|
100
|
+
"term": c["term"],
|
|
101
|
+
"slug": c["slug"],
|
|
102
|
+
"category": c["category"],
|
|
103
|
+
"kind": c.get("kind", "core"),
|
|
104
|
+
"summary": c.get("short_definition", ""),
|
|
105
|
+
"score": round(score, 1),
|
|
106
|
+
}
|
|
107
|
+
for score, c in scored[:limit]
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
# ---------- answering ----------
|
|
111
|
+
def answer(self, question: str) -> dict:
|
|
112
|
+
"""对外入口:先执行「学说与个人分离」政策,再走检索路由。"""
|
|
113
|
+
q = clean(question)
|
|
114
|
+
policy = (self.meta.get("stats") or {}).get("policy") or {}
|
|
115
|
+
excluded = policy.get("excluded_entities") or []
|
|
116
|
+
if q and any(e in q for e in excluded):
|
|
117
|
+
stripped = q
|
|
118
|
+
for e in excluded:
|
|
119
|
+
stripped = stripped.replace(f"{e}的", "").replace(e, "")
|
|
120
|
+
stripped = stripped.strip(",,。??;; ")
|
|
121
|
+
if len(stripped) >= 4:
|
|
122
|
+
result = self._answer_core(stripped)
|
|
123
|
+
result["policy_note"] = policy.get("answer", "")
|
|
124
|
+
result["question"] = q
|
|
125
|
+
return result
|
|
126
|
+
return {
|
|
127
|
+
"ok": True,
|
|
128
|
+
"type": "policy",
|
|
129
|
+
"question": q,
|
|
130
|
+
"answer": policy.get("answer", ""),
|
|
131
|
+
"context": "",
|
|
132
|
+
"citations": [],
|
|
133
|
+
"related": [],
|
|
134
|
+
"next": [],
|
|
135
|
+
"confidence": "high",
|
|
136
|
+
}
|
|
137
|
+
return self._answer_core(q)
|
|
138
|
+
|
|
139
|
+
_ENUM_RE = re.compile(
|
|
140
|
+
r"([\u4e00-\u9fa5A-Za-z]{2,10}(?:、[\u4e00-\u9fa5A-Za-z]{2,10})+)"
|
|
141
|
+
r"(?:分别|各自)?(?:是什么|指什么|的定义|的含义|各是什么)"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _enumeration_answer(self, question: str) -> dict | None:
|
|
145
|
+
m = self._ENUM_RE.search(question)
|
|
146
|
+
if not m:
|
|
147
|
+
return None
|
|
148
|
+
names = [n.strip() for n in m.group(1).split("、") if n.strip()]
|
|
149
|
+
concepts = []
|
|
150
|
+
for name in names:
|
|
151
|
+
c = self.resolve(name)
|
|
152
|
+
if c and c["slug"] not in {x["slug"] for x in concepts}:
|
|
153
|
+
concepts.append(c)
|
|
154
|
+
if len(concepts) < 2:
|
|
155
|
+
return None
|
|
156
|
+
lines, citations = [], []
|
|
157
|
+
for c in concepts:
|
|
158
|
+
lines.append(f"【{c['term']}】{c['definition']}")
|
|
159
|
+
if c.get("sources"):
|
|
160
|
+
s0 = c["sources"][0]
|
|
161
|
+
citations.append({"doc": s0["doc_title"], "section": s0.get("section", ""),
|
|
162
|
+
"quote": s0.get("quote", "")})
|
|
163
|
+
answer = "\n\n".join(lines)
|
|
164
|
+
related = [{"term": c["term"], "slug": c["slug"],
|
|
165
|
+
"summary": c.get("short_definition", "")} for c in concepts]
|
|
166
|
+
return {
|
|
167
|
+
"ok": True, "type": "multi", "question": question, "answer": answer,
|
|
168
|
+
"context": self._context_block(answer, citations,
|
|
169
|
+
[{"term": c["term"], "summary": c.get("short_definition", "")}
|
|
170
|
+
for c in concepts]),
|
|
171
|
+
"citations": citations, "related": related,
|
|
172
|
+
"next": [{"rel": "related", "term": c["term"],
|
|
173
|
+
"href": f"/api/v1/concept/{c['slug']}"} for c in concepts],
|
|
174
|
+
"confidence": "high",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def _answer_core(self, question: str) -> dict:
|
|
178
|
+
"""复合问句:先试枚举(A、B、C 分别是什么),再按标点拆段路由。"""
|
|
179
|
+
q = clean(question)
|
|
180
|
+
if not q:
|
|
181
|
+
return self._none(q)
|
|
182
|
+
enum = self._enumeration_answer(q)
|
|
183
|
+
if enum is not None:
|
|
184
|
+
return enum
|
|
185
|
+
segments = [seg.strip(" ,,。") for seg in re.split(r"[??。;;]", q) if seg.strip(" ,,。")]
|
|
186
|
+
if len(segments) > 1:
|
|
187
|
+
fallback = None
|
|
188
|
+
for seg in segments:
|
|
189
|
+
result = self._route_single(seg)
|
|
190
|
+
if result.get("type") in ("relation", "concept", "faq"):
|
|
191
|
+
result["question"] = q
|
|
192
|
+
return result
|
|
193
|
+
if fallback is None:
|
|
194
|
+
fallback = result
|
|
195
|
+
if fallback is not None:
|
|
196
|
+
fallback["question"] = q
|
|
197
|
+
return fallback
|
|
198
|
+
return self._route_single(q)
|
|
199
|
+
|
|
200
|
+
def _route_single(self, question: str) -> dict:
|
|
201
|
+
q = clean(question)
|
|
202
|
+
if not q:
|
|
203
|
+
return self._none(q)
|
|
204
|
+
|
|
205
|
+
qg = _bigrams(q)
|
|
206
|
+
best_faq, best_score = None, 0.0
|
|
207
|
+
for faq, grams in self._faq_grams:
|
|
208
|
+
if not self._faq_relevant(faq, q):
|
|
209
|
+
continue
|
|
210
|
+
score = _jaccard(qg, grams)
|
|
211
|
+
if score > best_score:
|
|
212
|
+
best_faq, best_score = faq, score
|
|
213
|
+
|
|
214
|
+
# 0) 关系型问题:A 与 B 是什么关系 / 有什么区别
|
|
215
|
+
link = self._relation_question(q)
|
|
216
|
+
if link is not None:
|
|
217
|
+
return link
|
|
218
|
+
|
|
219
|
+
# 1) 与标准问答高度重合时优先返回问答,避免被术语别名劫持
|
|
220
|
+
if best_faq is not None and best_score >= 0.6:
|
|
221
|
+
return self._from_faq(best_faq, q, best_score)
|
|
222
|
+
|
|
223
|
+
# 2) 术语直查:精确命中,或「什么是 X / X 是什么」问法
|
|
224
|
+
concept = self._alias.get(q) or self._alias.get(q.lower()) or self._alias.get(self._strip_question(q))
|
|
225
|
+
if concept is not None:
|
|
226
|
+
return self._from_concept(concept, q)
|
|
227
|
+
|
|
228
|
+
# 3) 退一步的问答相似度
|
|
229
|
+
if best_faq is not None and best_score >= 0.34:
|
|
230
|
+
return self._from_faq(best_faq, q, best_score)
|
|
231
|
+
|
|
232
|
+
hits = self.search(q, limit=3)
|
|
233
|
+
if hits:
|
|
234
|
+
lines = ["没有直接命中标准问答,以下是相关词条:"]
|
|
235
|
+
for h in hits:
|
|
236
|
+
lines.append(f"· {h['term']}:{h['summary']}")
|
|
237
|
+
return {
|
|
238
|
+
"ok": True,
|
|
239
|
+
"type": "related",
|
|
240
|
+
"question": q,
|
|
241
|
+
"answer": "\n".join(lines),
|
|
242
|
+
"context": "\n".join(lines),
|
|
243
|
+
"citations": [],
|
|
244
|
+
"related": hits,
|
|
245
|
+
"did_you_mean": [h["term"] for h in hits],
|
|
246
|
+
"next": [
|
|
247
|
+
{"rel": "search", "term": h["term"], "href": f"/api/v1/concept/{h['slug']}"}
|
|
248
|
+
for h in hits
|
|
249
|
+
],
|
|
250
|
+
"confidence": "low",
|
|
251
|
+
}
|
|
252
|
+
return self._none(q)
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _strip_question(text: str) -> str:
|
|
256
|
+
s = text.strip()
|
|
257
|
+
for prefix in ("请问", "请教", "什么是", "何为", "什么叫"):
|
|
258
|
+
if s.startswith(prefix):
|
|
259
|
+
s = s[len(prefix):]
|
|
260
|
+
for suffix in ("是什么意思", "的定义是什么", "的定义", "是什么", "指什么", "?", "?"):
|
|
261
|
+
if s.endswith(suffix):
|
|
262
|
+
s = s[: -len(suffix)]
|
|
263
|
+
return s.strip(",,。;; ")
|
|
264
|
+
|
|
265
|
+
_RELATION_RE = re.compile(
|
|
266
|
+
r"(.{2,20}?)(?:和|与|跟|同)(.{2,20}?)(?:是什么关系|有什么关系|关系是什么|"
|
|
267
|
+
r"有何关系|关系如何|的区别是什么|有什么区别|有何区别|区别是什么)"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _relation_question(self, question: str) -> dict | None:
|
|
271
|
+
m = self._RELATION_RE.search(question)
|
|
272
|
+
if not m:
|
|
273
|
+
return None
|
|
274
|
+
a = self.resolve(m.group(1).strip(",,。 "))
|
|
275
|
+
b = self.resolve(m.group(2).strip(",,。 "))
|
|
276
|
+
if not a or not b or a["slug"] == b["slug"]:
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
edge = next((r for r in a.get("relations") or [] if r.get("target") == b["term"]), None)
|
|
280
|
+
reverse = next((r for r in b.get("relations") or [] if r.get("target") == a["term"]), None)
|
|
281
|
+
edge = edge or reverse
|
|
282
|
+
about = ""
|
|
283
|
+
if edge:
|
|
284
|
+
about = f"{a['term']}与{b['term']}的关系是「{edge['type']}」。"
|
|
285
|
+
if edge.get("evidence"):
|
|
286
|
+
about += f"原文:{edge['evidence']}"
|
|
287
|
+
else:
|
|
288
|
+
about = f"知识库中没有直接记录{a['term']}与{b['term']}的关系,以下为两者的定义,供比对。"
|
|
289
|
+
|
|
290
|
+
citations = []
|
|
291
|
+
for concept in (a, b):
|
|
292
|
+
if concept.get("sources"):
|
|
293
|
+
s = concept["sources"][0]
|
|
294
|
+
citations.append({"doc": s["doc_title"], "section": s.get("section", ""), "quote": s.get("quote", "")})
|
|
295
|
+
|
|
296
|
+
answer = (
|
|
297
|
+
f"{about}\n\n"
|
|
298
|
+
f"【{a['term']}】{a['definition']}\n\n"
|
|
299
|
+
f"【{b['term']}】{b['definition']}"
|
|
300
|
+
)
|
|
301
|
+
return {
|
|
302
|
+
"ok": True,
|
|
303
|
+
"type": "relation",
|
|
304
|
+
"question": question,
|
|
305
|
+
"answer": answer,
|
|
306
|
+
"context": self._context_block(answer, citations, [
|
|
307
|
+
{"term": a["term"], "summary": a.get("short_definition", "")},
|
|
308
|
+
{"term": b["term"], "summary": b.get("short_definition", "")},
|
|
309
|
+
]),
|
|
310
|
+
"relation": {"source": a["term"], "target": b["term"], "type": edge["type"] if edge else ""},
|
|
311
|
+
"citations": citations,
|
|
312
|
+
"related": [
|
|
313
|
+
{"term": a["term"], "slug": a["slug"], "summary": a.get("short_definition", "")},
|
|
314
|
+
{"term": b["term"], "slug": b["slug"], "summary": b.get("short_definition", "")},
|
|
315
|
+
],
|
|
316
|
+
"next": [
|
|
317
|
+
{"rel": "related", "term": a["term"], "href": f"/api/v1/concept/{a['slug']}"},
|
|
318
|
+
{"rel": "related", "term": b["term"], "href": f"/api/v1/concept/{b['slug']}"},
|
|
319
|
+
],
|
|
320
|
+
"confidence": "high" if edge else "medium",
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
def _from_faq(self, faq: dict, question: str, score: float) -> dict:
|
|
324
|
+
return {
|
|
325
|
+
"ok": True,
|
|
326
|
+
"type": "faq",
|
|
327
|
+
"question": question,
|
|
328
|
+
"answer": faq["answer"],
|
|
329
|
+
"context": self._context_block(faq["answer"], self._faq_citations(faq),
|
|
330
|
+
self._related_entries(faq.get("related") or [])),
|
|
331
|
+
"citations": self._faq_citations(faq),
|
|
332
|
+
"related": self._related_entries(faq.get("related") or []),
|
|
333
|
+
"next": [
|
|
334
|
+
{"rel": "related", "term": r["term"], "href": f"/api/v1/concept/{r['slug']}"}
|
|
335
|
+
for r in self._related_entries(faq.get("related") or [])[:6]
|
|
336
|
+
],
|
|
337
|
+
"confidence": "high" if score >= 0.6 else "medium",
|
|
338
|
+
"matched_question": faq["question"],
|
|
339
|
+
"score": round(score, 3),
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
def _none(self, question: str) -> dict:
|
|
343
|
+
hits = self.search(question, limit=3)
|
|
344
|
+
return {
|
|
345
|
+
"ok": True,
|
|
346
|
+
"type": "none",
|
|
347
|
+
"question": question,
|
|
348
|
+
"answer": "知识库中未找到与该问题匹配的术语或问答。",
|
|
349
|
+
"context": "",
|
|
350
|
+
"citations": [],
|
|
351
|
+
"related": [],
|
|
352
|
+
"did_you_mean": [h["term"] for h in hits],
|
|
353
|
+
"next": [
|
|
354
|
+
{"rel": "search", "term": h["term"], "href": f"/api/v1/concept/{h['slug']}"}
|
|
355
|
+
for h in hits
|
|
356
|
+
],
|
|
357
|
+
"confidence": "low",
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
def _next_links(self, concept: dict) -> list[dict]:
|
|
361
|
+
out = []
|
|
362
|
+
for r in (concept.get("relations") or [])[:6]:
|
|
363
|
+
if not r.get("target_id") or r.get("target") == concept.get("term"):
|
|
364
|
+
continue
|
|
365
|
+
out.append({"rel": r["type"], "term": r["target"],
|
|
366
|
+
"href": f"/api/v1/concept/{r['target_id']}"})
|
|
367
|
+
return out
|
|
368
|
+
|
|
369
|
+
@staticmethod
|
|
370
|
+
def _context_block(answer: str, citations: list[dict], related: list[dict]) -> str:
|
|
371
|
+
"""可直接注入上下文的紧凑块:答案 + 出处原句 + 相关概念一句话。"""
|
|
372
|
+
parts = [answer]
|
|
373
|
+
for c in citations[:2]:
|
|
374
|
+
quote = (c.get("quote") or "")[:160]
|
|
375
|
+
if quote:
|
|
376
|
+
parts.append(f"出处({c.get('doc', '')}):{quote}")
|
|
377
|
+
for r in related[:4]:
|
|
378
|
+
if r.get("summary"):
|
|
379
|
+
parts.append(f"相关:{r['term']} —— {r['summary']}")
|
|
380
|
+
return "\n".join(parts)[:900]
|
|
381
|
+
|
|
382
|
+
def _from_concept(self, concept: dict, question: str) -> dict:
|
|
383
|
+
citations = [
|
|
384
|
+
{"doc": s["doc_title"], "section": s.get("section", ""), "quote": s.get("quote", "")}
|
|
385
|
+
for s in (concept.get("sources") or [])[:2]
|
|
386
|
+
]
|
|
387
|
+
evidence = sorted(concept.get("evidence") or [], key=lambda e: -e.get("score", 0))
|
|
388
|
+
if evidence:
|
|
389
|
+
top = evidence[0]
|
|
390
|
+
citations.append({
|
|
391
|
+
"doc": top["doc_title"],
|
|
392
|
+
"section": top.get("section", ""),
|
|
393
|
+
"quote": top["quote"],
|
|
394
|
+
})
|
|
395
|
+
return {
|
|
396
|
+
"ok": True,
|
|
397
|
+
"type": "concept",
|
|
398
|
+
"question": question,
|
|
399
|
+
"term": concept["term"],
|
|
400
|
+
"answer": concept["definition"],
|
|
401
|
+
"short_answer": concept.get("short_definition", ""),
|
|
402
|
+
"context": self._context_block(
|
|
403
|
+
concept["definition"], citations,
|
|
404
|
+
[{"term": r["target"], "summary": (self.by_term.get(r["target"]) or {}).get("short_definition", "")}
|
|
405
|
+
for r in (concept.get("relations") or [])[:4]],
|
|
406
|
+
),
|
|
407
|
+
"citations": citations,
|
|
408
|
+
"related": [
|
|
409
|
+
{"term": r["target"], "slug": r.get("target_id", ""), "relation": r["type"]}
|
|
410
|
+
for r in (concept.get("relations") or [])[:8]
|
|
411
|
+
],
|
|
412
|
+
"next": self._next_links(concept),
|
|
413
|
+
"confidence": "high" if concept.get("status") == "ok" else "medium",
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
@staticmethod
|
|
417
|
+
def _faq_relevant(faq: dict, question: str) -> bool:
|
|
418
|
+
terms = [t for t in (faq.get("source_terms") or []) if t]
|
|
419
|
+
if not terms:
|
|
420
|
+
return True
|
|
421
|
+
return any(t in question for t in terms)
|
|
422
|
+
|
|
423
|
+
def _faq_citations(self, faq: dict) -> list[dict]:
|
|
424
|
+
out = []
|
|
425
|
+
for term in faq.get("source_terms") or []:
|
|
426
|
+
concept = self.by_term.get(term)
|
|
427
|
+
if concept and concept.get("sources"):
|
|
428
|
+
s = concept["sources"][0]
|
|
429
|
+
out.append({"doc": s["doc_title"], "section": s.get("section", ""), "quote": s.get("quote", "")})
|
|
430
|
+
break
|
|
431
|
+
return out
|
|
432
|
+
|
|
433
|
+
def _related_entries(self, ids: list[str]) -> list[dict]:
|
|
434
|
+
out = []
|
|
435
|
+
for i in ids:
|
|
436
|
+
c = self.by_slug.get(i)
|
|
437
|
+
if c:
|
|
438
|
+
out.append({"term": c["term"], "slug": c["slug"], "summary": c.get("short_definition", "")})
|
|
439
|
+
return out
|
|
440
|
+
|
|
441
|
+
# ---------- graph ----------
|
|
442
|
+
def related(self, term: str, depth: int = 1) -> dict:
|
|
443
|
+
concept = self.resolve(term)
|
|
444
|
+
if concept is None:
|
|
445
|
+
return {"ok": False, "error": f"未找到术语:{term}"}
|
|
446
|
+
nodes = {concept["slug"]: {
|
|
447
|
+
"term": concept["term"], "slug": concept["slug"],
|
|
448
|
+
"category": concept["category"], "kind": concept.get("kind", "core"),
|
|
449
|
+
"summary": concept.get("short_definition", ""),
|
|
450
|
+
}}
|
|
451
|
+
edges = []
|
|
452
|
+
frontier = [concept]
|
|
453
|
+
for _ in range(max(1, min(depth, 2))):
|
|
454
|
+
nxt = []
|
|
455
|
+
for node in frontier:
|
|
456
|
+
for r in node.get("relations") or []:
|
|
457
|
+
target = self.by_slug.get(r.get("target_id", ""))
|
|
458
|
+
if not target:
|
|
459
|
+
continue
|
|
460
|
+
edges.append({"source": node["term"], "target": target["term"], "type": r["type"]})
|
|
461
|
+
if target["slug"] not in nodes:
|
|
462
|
+
nodes[target["slug"]] = {
|
|
463
|
+
"term": target["term"], "slug": target["slug"],
|
|
464
|
+
"category": target["category"], "kind": target.get("kind", "core"),
|
|
465
|
+
"summary": target.get("short_definition", ""),
|
|
466
|
+
}
|
|
467
|
+
nxt.append(target)
|
|
468
|
+
frontier = nxt
|
|
469
|
+
return {"ok": True, "center": concept["term"], "nodes": list(nodes.values()), "edges": edges}
|
|
470
|
+
|
|
471
|
+
def catalog(self, category: str | None = None) -> dict:
|
|
472
|
+
items = [
|
|
473
|
+
{
|
|
474
|
+
"term": c["term"], "slug": c["slug"], "category": c["category"],
|
|
475
|
+
"kind": c.get("kind", "core"), "en": c.get("en", ""),
|
|
476
|
+
"summary": c.get("short_definition", ""),
|
|
477
|
+
}
|
|
478
|
+
for c in self.concepts
|
|
479
|
+
if not category or c["category"] == category
|
|
480
|
+
]
|
|
481
|
+
return {"count": len(items), "categories": self.meta.get("stats", {}).get("categories", {}), "items": items}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
_cache: dict[str, tuple[float, Knowledge]] = {}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def load(root: str | Path) -> Knowledge:
|
|
488
|
+
path = Path(root) / "data" / "kb.json"
|
|
489
|
+
if not path.exists():
|
|
490
|
+
from . import pipeline
|
|
491
|
+
pipeline.rebuild(root)
|
|
492
|
+
mtime = path.stat().st_mtime
|
|
493
|
+
cached = _cache.get(str(path))
|
|
494
|
+
if cached and cached[0] == mtime:
|
|
495
|
+
return cached[1]
|
|
496
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
497
|
+
kb = Knowledge(payload)
|
|
498
|
+
_cache[str(path)] = (mtime, kb)
|
|
499
|
+
return kb
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def answer_markdown(result: dict) -> str:
|
|
503
|
+
"""把问答结果渲染成便于 Agent 直接嵌入回答的 Markdown。"""
|
|
504
|
+
lines = [f"**{result.get('question', '')}**", "", result.get("answer", "")]
|
|
505
|
+
cites = result.get("citations") or []
|
|
506
|
+
if cites:
|
|
507
|
+
lines += ["", "出处:"]
|
|
508
|
+
for c in cites[:3]:
|
|
509
|
+
lines.append(f"- {c.get('doc', '')}({c.get('section', '')}):{c.get('quote', '')[:120]}")
|
|
510
|
+
related = result.get("related") or []
|
|
511
|
+
if related:
|
|
512
|
+
lines += ["", "相关概念:" + "、".join(r.get("term", "") for r in related[:5] if r.get("term"))]
|
|
513
|
+
if result.get("did_you_mean"):
|
|
514
|
+
lines += ["", "你可能想问:" + "、".join(result["did_you_mean"])]
|
|
515
|
+
return "\n".join(lines)
|
mcai/llm.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""DeepSeek API 客户端(标准库实现,无第三方依赖)。
|
|
2
|
+
|
|
3
|
+
密钥从 config/llm.json 指向的文件读取,也可用环境变量 DEEPSEEK_API_KEY 覆盖。
|
|
4
|
+
密钥不会被写进任何产物或日志。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE = "https://api.deepseek.com"
|
|
16
|
+
DEFAULT_MODEL = "deepseek-chat"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LLMError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LLM:
|
|
24
|
+
def __init__(self, config_path: str | Path | None = None):
|
|
25
|
+
cfg: dict = {}
|
|
26
|
+
if config_path:
|
|
27
|
+
p = Path(config_path)
|
|
28
|
+
if p.exists():
|
|
29
|
+
cfg = json.loads(p.read_text(encoding="utf-8-sig"))
|
|
30
|
+
self.base_url = (cfg.get("base_url") or DEFAULT_BASE).rstrip("/")
|
|
31
|
+
self.model = cfg.get("model") or DEFAULT_MODEL
|
|
32
|
+
self.timeout = int(cfg.get("timeout", 60))
|
|
33
|
+
self.retries = int(cfg.get("retries", 2))
|
|
34
|
+
self.key = os.environ.get("DEEPSEEK_API_KEY") or self._read_key(cfg.get("key_path"))
|
|
35
|
+
self.calls = 0
|
|
36
|
+
self.prompt_tokens = 0
|
|
37
|
+
self.completion_tokens = 0
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _read_key(path: str | None) -> str:
|
|
41
|
+
if not path:
|
|
42
|
+
return ""
|
|
43
|
+
p = Path(path)
|
|
44
|
+
return p.read_text(encoding="utf-8-sig").strip() if p.exists() else ""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def available(self) -> bool:
|
|
48
|
+
return bool(self.key)
|
|
49
|
+
|
|
50
|
+
def chat(self, messages: list[dict], temperature: float = 0.0,
|
|
51
|
+
max_tokens: int = 700) -> str:
|
|
52
|
+
if not self.available:
|
|
53
|
+
raise LLMError("未找到 API 密钥:请检查 config/llm.json 的 key_path 或环境变量 DEEPSEEK_API_KEY")
|
|
54
|
+
payload = json.dumps({
|
|
55
|
+
"model": self.model,
|
|
56
|
+
"messages": messages,
|
|
57
|
+
"temperature": temperature,
|
|
58
|
+
"max_tokens": max_tokens,
|
|
59
|
+
"stream": False,
|
|
60
|
+
}, ensure_ascii=False).encode("utf-8")
|
|
61
|
+
|
|
62
|
+
last_error: Exception | None = None
|
|
63
|
+
for attempt in range(self.retries + 1):
|
|
64
|
+
req = urllib.request.Request(
|
|
65
|
+
f"{self.base_url}/chat/completions",
|
|
66
|
+
data=payload,
|
|
67
|
+
headers={
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"Authorization": f"Bearer {self.key}",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
74
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
75
|
+
usage = data.get("usage") or {}
|
|
76
|
+
self.calls += 1
|
|
77
|
+
self.prompt_tokens += int(usage.get("prompt_tokens") or 0)
|
|
78
|
+
self.completion_tokens += int(usage.get("completion_tokens") or 0)
|
|
79
|
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
|
80
|
+
except urllib.error.HTTPError as exc:
|
|
81
|
+
body = exc.read().decode("utf-8", "replace")[:300]
|
|
82
|
+
last_error = LLMError(f"HTTP {exc.code}: {body}")
|
|
83
|
+
if exc.code in (400, 401, 403, 404):
|
|
84
|
+
break
|
|
85
|
+
except (urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError) as exc:
|
|
86
|
+
last_error = LLMError(f"{type(exc).__name__}: {exc}")
|
|
87
|
+
if attempt < self.retries:
|
|
88
|
+
time.sleep(1.5 * (attempt + 1))
|
|
89
|
+
raise last_error or LLMError("调用失败")
|
|
90
|
+
|
|
91
|
+
def stats(self) -> dict:
|
|
92
|
+
return {
|
|
93
|
+
"model": self.model,
|
|
94
|
+
"calls": self.calls,
|
|
95
|
+
"prompt_tokens": self.prompt_tokens,
|
|
96
|
+
"completion_tokens": self.completion_tokens,
|
|
97
|
+
}
|