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