knowledge_hub_service.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392
  1. from __future__ import annotations
  2. import asyncio
  3. import re
  4. import time
  5. from collections import Counter
  6. from collections.abc import AsyncIterator
  7. from functools import lru_cache
  8. from typing import Any
  9. from urllib.parse import quote
  10. import httpx
  11. from sqlalchemy.orm import Session
  12. from app.agents.prompts import (
  13. KNOWLEDGE_ASSISTANT_CHAT_PROMPT,
  14. KNOWLEDGE_ASSISTANT_CHAT_SYSTEM_PROMPT,
  15. KNOWLEDGE_RAG_PROMPT,
  16. KNOWLEDGE_RAG_SYSTEM_PROMPT,
  17. )
  18. from app.core.config import settings
  19. from app.core.errors import APIError
  20. from app.db.repositories import knowledge as knowledge_repo
  21. from app.schemas.knowledge import (
  22. KnowledgeAskRequest,
  23. KnowledgeAskResponse,
  24. KnowledgeCatalogItem,
  25. KnowledgeCatalogResponse,
  26. KnowledgeCategoryStat,
  27. KnowledgeGraphEdge,
  28. KnowledgeGraphNode,
  29. KnowledgeGraphResponse,
  30. KnowledgeNoteChunk,
  31. KnowledgeNoteDetail,
  32. KnowledgeOverviewResponse,
  33. KnowledgeServiceStatus,
  34. KnowledgeSource,
  35. )
  36. CATEGORY_DEFS: tuple[tuple[str, str, str], ...] = (
  37. ("standards", "规范标准", "规程、标准、办法及技术要求"),
  38. ("cases", "事故案例", "典型事故与隐患处置案例"),
  39. ("expert", "专家经验", "专家规则、判识经验与处置建议"),
  40. ("management", "管理流程", "业务流程、责任岗位与闭环规则"),
  41. )
  42. CATEGORY_NAMES = {key: name for key, name, _ in CATEGORY_DEFS}
  43. DOMAIN_TERMS: tuple[str, ...] = (
  44. "局部通风机",
  45. "局部通风",
  46. "主要通风机",
  47. "煤与瓦斯突出",
  48. "煤矿安全规程",
  49. "矿井防灭火",
  50. "矿井防尘",
  51. "监测监控",
  52. "掘进通风",
  53. "通风系统",
  54. "通风设施",
  55. "停风",
  56. "瓦斯超限",
  57. "瓦斯抽采",
  58. "粉尘防治",
  59. "自然发火",
  60. "一氧化碳",
  61. "二氧化碳",
  62. "循环风",
  63. "风筒",
  64. "风门",
  65. "密闭",
  66. "漏风",
  67. "风量",
  68. "风速",
  69. "阻力",
  70. "瓦斯",
  71. "甲烷",
  72. "防尘",
  73. "防火",
  74. "火灾",
  75. "传感器",
  76. "隐患",
  77. "处置",
  78. "标准",
  79. "规程",
  80. "措施",
  81. "判定",
  82. )
  83. GENERIC_QUERY_TERMS = {"处置", "标准", "规程", "措施", "判定"}
  84. SECTION_RANGE_RE = re.compile(
  85. r"(\d+(?:\.\d+){1,5})\s*[—–~~至到\-一]\s*(\d+(?:\.\d+){1,5})"
  86. )
  87. SECTION_NUMBER_RE = re.compile(r"(?<![\d.])(\d{1,3}(?:\.\d{1,3}){2,5})(?![\d.])")
  88. SECTION_HEADING_RE = re.compile(r"^\s*(\d+(?:\.\d+){1,5})(?:[\s、..::-]|$)")
  89. CONVERSATIONAL_TERMS = (
  90. "你好",
  91. "您好",
  92. "您好呀",
  93. "在吗",
  94. "早上好",
  95. "上午好",
  96. "下午好",
  97. "晚上好",
  98. "谢谢",
  99. "感谢",
  100. "你是谁",
  101. "你叫什么",
  102. "你能做什么",
  103. "你可以做什么",
  104. "怎么使用",
  105. "帮助",
  106. "hello",
  107. "hi",
  108. )
  109. ACTION_WORDS: tuple[str, ...] = (
  110. "应当",
  111. "必须",
  112. "严禁",
  113. "不得",
  114. "立即",
  115. "停止",
  116. "撤出",
  117. "切断",
  118. "检查",
  119. "确认",
  120. "设置",
  121. "恢复",
  122. "复测",
  123. "处置",
  124. "排查",
  125. "调整",
  126. "关闭",
  127. "启动",
  128. "监测",
  129. )
  130. class KnowledgeHubClient:
  131. def __init__(
  132. self,
  133. base_url: str | None = None,
  134. api_key: str | None = None,
  135. timeout: float | None = None,
  136. ) -> None:
  137. configured_base = base_url if base_url is not None else settings.knowledge_service_base
  138. configured_key = api_key if api_key is not None else settings.knowledge_service_api_key
  139. self.base_url = configured_base.strip().rstrip("/")
  140. self.api_key = configured_key.strip()
  141. self.timeout = timeout if timeout is not None else settings.knowledge_service_timeout
  142. @property
  143. def configured(self) -> bool:
  144. return bool(self.base_url)
  145. async def _request(
  146. self,
  147. method: str,
  148. path: str,
  149. *,
  150. params: dict[str, Any] | None = None,
  151. json: dict[str, Any] | None = None,
  152. ) -> Any:
  153. if not self.configured:
  154. raise APIError(503, "knowledge_service_not_configured", "知识库服务地址未配置")
  155. headers = {"Accept": "application/json"}
  156. if self.api_key:
  157. headers["Authorization"] = f"Bearer {self.api_key}"
  158. timeout = httpx.Timeout(self.timeout, connect=min(self.timeout, 10.0))
  159. try:
  160. async with httpx.AsyncClient(
  161. base_url=self.base_url,
  162. headers=headers,
  163. timeout=timeout,
  164. follow_redirects=True,
  165. ) as client:
  166. response = await client.request(method, path, params=params, json=json)
  167. response.raise_for_status()
  168. if response.status_code == 204:
  169. return None
  170. return response.json()
  171. except httpx.HTTPStatusError as exc:
  172. detail = exc.response.text[:500]
  173. raise APIError(
  174. 502,
  175. "knowledge_service_http_error",
  176. f"知识库服务返回异常状态:{exc.response.status_code}",
  177. detail=detail,
  178. ) from exc
  179. except (httpx.RequestError, ValueError) as exc:
  180. raise APIError(
  181. 503,
  182. "knowledge_service_unavailable",
  183. "知识库服务暂时不可用",
  184. detail=f"{type(exc).__name__}: {exc}",
  185. ) from exc
  186. async def status(self) -> KnowledgeServiceStatus:
  187. if not self.configured:
  188. return KnowledgeServiceStatus(
  189. base_url="",
  190. error="知识库服务地址未配置",
  191. )
  192. try:
  193. payload = await self._request("GET", "/api/admin/status")
  194. except APIError as exc:
  195. return KnowledgeServiceStatus(
  196. base_url=self.base_url,
  197. error=exc.message,
  198. )
  199. stats = payload.get("stats") or {}
  200. embedding_model = str(payload.get("embedding_model") or "")
  201. return KnowledgeServiceStatus(
  202. available=True,
  203. base_url=self.base_url,
  204. mode=str(payload.get("mode") or ""),
  205. status=str(payload.get("status") or ""),
  206. total_notes=int(stats.get("total_notes") or 0),
  207. total_chunks=int(stats.get("total_chunks") or 0),
  208. embedding_model=embedding_model,
  209. semantic_enabled=embedding_model.lower() not in {"", "disabled", "none"},
  210. last_indexed=stats.get("last_indexed"),
  211. )
  212. async def tags(self) -> list[dict[str, Any]]:
  213. payload = await self._request("GET", "/api/tags")
  214. return list(payload.get("tags") or [])
  215. async def notes_page(self, page: int, page_size: int = 200) -> dict[str, Any]:
  216. return await self._request(
  217. "GET",
  218. "/api/notes",
  219. params={"page": page, "page_size": page_size},
  220. )
  221. async def note(self, note_id: str) -> dict[str, Any]:
  222. return await self._request("GET", f"/api/notes/{quote(note_id, safe='/')}")
  223. async def note_full(self, note_id: str) -> dict[str, Any]:
  224. return await self._request("POST", "/api/note_full", json={"note_id": note_id})
  225. async def search(
  226. self,
  227. query: str,
  228. *,
  229. mode: str = "keyword",
  230. top_k: int = 5,
  231. tag: str | None = None,
  232. min_score: float = 0.0,
  233. min_match_ratio: float = 0.0,
  234. ) -> dict[str, Any]:
  235. params: dict[str, Any] = {
  236. "q": query,
  237. "mode": mode,
  238. "top_k": top_k,
  239. "min_score": min_score,
  240. "min_match_ratio": min_match_ratio,
  241. }
  242. if tag:
  243. params["tag"] = tag
  244. return await self._request("GET", "/api/search", params=params)
  245. @lru_cache(maxsize=1)
  246. def get_knowledge_hub_client() -> KnowledgeHubClient:
  247. return KnowledgeHubClient()
  248. _STATUS_CACHE: tuple[float, KnowledgeServiceStatus] | None = None
  249. _CATALOG_CACHE: tuple[float, list[dict[str, Any]]] | None = None
  250. _CATALOG_LOCKS: dict[int, asyncio.Lock] = {}
  251. def reset_knowledge_hub_state() -> None:
  252. global _STATUS_CACHE, _CATALOG_CACHE, _CATALOG_LOCKS
  253. _STATUS_CACHE = None
  254. _CATALOG_CACHE = None
  255. _CATALOG_LOCKS = {}
  256. get_knowledge_hub_client.cache_clear()
  257. async def fetch_status(*, force: bool = False) -> KnowledgeServiceStatus:
  258. global _STATUS_CACHE
  259. now = time.monotonic()
  260. if not force and _STATUS_CACHE and now - _STATUS_CACHE[0] < 30:
  261. return _STATUS_CACHE[1]
  262. status = await get_knowledge_hub_client().status()
  263. _STATUS_CACHE = (now, status)
  264. return status
  265. async def fetch_catalog(*, force: bool = False) -> list[dict[str, Any]]:
  266. global _CATALOG_CACHE
  267. now = time.monotonic()
  268. if (
  269. not force
  270. and _CATALOG_CACHE
  271. and now - _CATALOG_CACHE[0] < max(settings.knowledge_catalog_cache_ttl, 1)
  272. ):
  273. return list(_CATALOG_CACHE[1])
  274. lock = _CATALOG_LOCKS.setdefault(id(asyncio.get_running_loop()), asyncio.Lock())
  275. async with lock:
  276. now = time.monotonic()
  277. if (
  278. not force
  279. and _CATALOG_CACHE
  280. and now - _CATALOG_CACHE[0] < max(settings.knowledge_catalog_cache_ttl, 1)
  281. ):
  282. return list(_CATALOG_CACHE[1])
  283. client = get_knowledge_hub_client()
  284. first = await client.notes_page(page=1, page_size=200)
  285. notes = list(first.get("notes") or [])
  286. total = int(first.get("total") or len(notes))
  287. page = 1
  288. while len(notes) < total and page < 20:
  289. page += 1
  290. payload = await client.notes_page(page=page, page_size=200)
  291. batch = list(payload.get("notes") or [])
  292. if not batch:
  293. break
  294. notes.extend(batch)
  295. _CATALOG_CACHE = (now, notes)
  296. return list(notes)
  297. def classify_note(note: dict[str, Any]) -> tuple[str, str]:
  298. note_id = str(note.get("note_id") or "")
  299. title = str(note.get("title") or "")
  300. tags = [str(item) for item in note.get("tags") or []]
  301. haystack = " ".join([note_id, title, *tags])
  302. if note_id.startswith(("queries/", "references/")) or any(
  303. tag == "workflow" or tag.startswith("workflow/") for tag in tags
  304. ):
  305. return "management", CATEGORY_NAMES["management"]
  306. if re.search(r"案例|典型事故|事故复盘", title) and "事故隐患判定" not in title:
  307. return "cases", CATEGORY_NAMES["cases"]
  308. if note_id.startswith(("concepts/", "entities/")):
  309. return "expert", CATEGORY_NAMES["expert"]
  310. if (
  311. note_id.startswith("raw/")
  312. or any(
  313. tag in {"source/standard", "source/regulation"}
  314. or tag.startswith(("regulation/", "standard/"))
  315. for tag in tags
  316. )
  317. or re.search(r"规程|规范|标准|细则|办法|技术条件|技术要求|MT/?T|AQ/?|GB/?", haystack, re.I)
  318. ):
  319. return "standards", CATEGORY_NAMES["standards"]
  320. return "other", "其他"
  321. def catalog_item(note: dict[str, Any]) -> KnowledgeCatalogItem:
  322. category, category_name = classify_note(note)
  323. return KnowledgeCatalogItem(
  324. note_id=str(note.get("note_id") or ""),
  325. title=str(note.get("title") or note.get("note_id") or ""),
  326. category=category,
  327. category_name=category_name,
  328. tags=[str(item) for item in note.get("tags") or []],
  329. aliases=[str(item) for item in note.get("aliases") or []],
  330. file_path=str(note.get("file_path") or ""),
  331. updated=str(note.get("updated") or note.get("created") or ""),
  332. chunk_count=int(note.get("chunk_count") or 0),
  333. confidence=str(note.get("confidence") or ""),
  334. status=str(note.get("status") or ""),
  335. version=str(note.get("version") or ""),
  336. )
  337. def _category_filter(note: dict[str, Any], category: str | None) -> bool:
  338. if not category or category == "all":
  339. return True
  340. return classify_note(note)[0] == category
  341. def _keyword_filter(note: dict[str, Any], keyword: str) -> bool:
  342. needle = keyword.casefold()
  343. values = [
  344. str(note.get("note_id") or ""),
  345. str(note.get("title") or ""),
  346. str(note.get("file_path") or ""),
  347. *[str(item) for item in note.get("tags") or []],
  348. *[str(item) for item in note.get("aliases") or []],
  349. ]
  350. return needle in " ".join(values).casefold()
  351. def _is_index_note(note: dict[str, Any]) -> bool:
  352. note_id = str(note.get("note_id") or "").casefold()
  353. title = str(note.get("title") or "").strip()
  354. return note_id.endswith("-index") or title.startswith("Source:")
  355. async def build_overview() -> KnowledgeOverviewResponse:
  356. status = await fetch_status()
  357. try:
  358. notes = await fetch_catalog()
  359. except APIError:
  360. notes = []
  361. category_counts = Counter(classify_note(note)[0] for note in notes)
  362. categories = [
  363. KnowledgeCategoryStat(
  364. key=key,
  365. name=name,
  366. count=category_counts.get(key, 0),
  367. description=description,
  368. available=category_counts.get(key, 0) > 0,
  369. )
  370. for key, name, description in CATEGORY_DEFS
  371. ]
  372. return KnowledgeOverviewResponse(
  373. service=status,
  374. total_notes=status.total_notes or len(notes),
  375. total_chunks=status.total_chunks,
  376. category_counts=categories,
  377. search_mode=settings.knowledge_search_mode,
  378. min_score=settings.knowledge_search_min_score,
  379. min_match_ratio=settings.knowledge_search_min_match_ratio,
  380. last_indexed=status.last_indexed,
  381. )
  382. async def build_catalog(
  383. *,
  384. page: int,
  385. page_size: int,
  386. category: str | None = None,
  387. keyword: str | None = None,
  388. tag: str | None = None,
  389. ) -> KnowledgeCatalogResponse:
  390. status = await fetch_status()
  391. notes = await fetch_catalog()
  392. filtered = [
  393. note
  394. for note in notes
  395. if _category_filter(note, category)
  396. and (not tag or tag in {str(item) for item in note.get("tags") or []})
  397. and (not keyword or _keyword_filter(note, keyword))
  398. ]
  399. filtered.sort(
  400. key=lambda note: (
  401. str(note.get("updated") or ""),
  402. float(note.get("mtime") or 0),
  403. ),
  404. reverse=True,
  405. )
  406. filtered.sort(key=_is_index_note)
  407. total = len(filtered)
  408. start = (page - 1) * page_size
  409. return KnowledgeCatalogResponse(
  410. items=[catalog_item(note) for note in filtered[start : start + page_size]],
  411. total=total,
  412. page=page,
  413. page_size=page_size,
  414. service=status,
  415. )
  416. async def build_note_detail(note_id: str) -> KnowledgeNoteDetail:
  417. notes = await fetch_catalog()
  418. note = next((item for item in notes if str(item.get("note_id")) == note_id), None)
  419. if note is None:
  420. raise APIError(404, "knowledge_note_not_found", "知识条目不存在")
  421. client = get_knowledge_hub_client()
  422. full_payload, detail_payload = await asyncio.gather(
  423. client.note_full(note_id),
  424. client.note(note_id),
  425. return_exceptions=True,
  426. )
  427. if isinstance(full_payload, Exception):
  428. raise full_payload
  429. detail = note if isinstance(detail_payload, Exception) else {**note, **detail_payload}
  430. item = catalog_item(detail)
  431. chunks = [
  432. KnowledgeNoteChunk(
  433. chunk_id=str(chunk.get("chunk_id") or ""),
  434. heading=str(chunk.get("heading") or ""),
  435. content=clean_knowledge_text(str(chunk.get("content") or "")),
  436. )
  437. for chunk in full_payload.get("chunks") or []
  438. ]
  439. links = detail.get("backlinks") or detail.get("links") or []
  440. return KnowledgeNoteDetail(
  441. **item.model_dump(),
  442. full_content=clean_knowledge_text(str(full_payload.get("full_content") or "")),
  443. links=list(links),
  444. chunks=chunks,
  445. )
  446. def extract_query_terms(question: str) -> list[str]:
  447. matched = [term for term in DOMAIN_TERMS if term in question]
  448. if matched:
  449. matched.sort(key=lambda term: (question.find(term), -len(term)))
  450. return list(dict.fromkeys(matched))[:4]
  451. cleaned = re.sub(
  452. r"请问|如何|应该|应该怎样|怎么|怎样|什么|哪些|是否|可以|需要|处置|处理|规定|标准|要求|后",
  453. " ",
  454. question,
  455. )
  456. segments = re.findall(r"[\u4e00-\u9fffA-Za-z0-9]{2,12}", cleaned)
  457. return list(dict.fromkeys(segments))[:4]
  458. def _normalize_search_mode(requested_mode: str | None, status: KnowledgeServiceStatus) -> str:
  459. mode = (requested_mode or settings.knowledge_search_mode or "keyword").strip().lower()
  460. if mode not in {"keyword", "semantic", "hybrid"}:
  461. mode = "keyword"
  462. if not status.semantic_enabled and mode != "keyword":
  463. return "keyword"
  464. return mode
  465. def _candidate_from_result(result: dict[str, Any]) -> dict[str, Any]:
  466. category, category_name = classify_note(
  467. {
  468. "note_id": result.get("note_id"),
  469. "title": result.get("title"),
  470. "tags": [],
  471. }
  472. )
  473. return {
  474. "chunk_id": str(result.get("chunk_id") or ""),
  475. "note_id": str(result.get("note_id") or ""),
  476. "title": str(result.get("title") or result.get("note_id") or ""),
  477. "file_path": str(result.get("file_path") or ""),
  478. "content": clean_knowledge_text(str(result.get("content") or "")),
  479. "heading": clean_knowledge_text(str(result.get("heading") or "")),
  480. "score": float(result.get("score") or 0),
  481. "category": category,
  482. "category_name": category_name,
  483. }
  484. async def search_candidates(
  485. question: str,
  486. *,
  487. requested_mode: str | None = None,
  488. top_k: int = 5,
  489. tag: str | None = None,
  490. ) -> tuple[list[dict[str, Any]], str, bool]:
  491. status = await fetch_status()
  492. mode = _normalize_search_mode(requested_mode, status)
  493. client = get_knowledge_hub_client()
  494. terms = extract_query_terms(question)
  495. specific_terms = [term for term in terms if term not in GENERIC_QUERY_TERMS]
  496. queries = list(dict.fromkeys([question, *(specific_terms or terms)[:3]]))
  497. responses = await asyncio.gather(
  498. *[
  499. client.search(
  500. query,
  501. mode=mode,
  502. top_k=max(top_k, 5),
  503. tag=tag,
  504. min_score=settings.knowledge_search_min_score,
  505. min_match_ratio=settings.knowledge_search_min_match_ratio,
  506. )
  507. for query in queries
  508. ],
  509. return_exceptions=True,
  510. )
  511. candidates: dict[str, dict[str, Any]] = {}
  512. unavailable = False
  513. for response in responses:
  514. if isinstance(response, Exception):
  515. unavailable = True
  516. continue
  517. for result in response.get("results") or []:
  518. candidate = _candidate_from_result(result)
  519. key = candidate["chunk_id"] or f"{candidate['note_id']}:{candidate['content'][:40]}"
  520. current = candidates.get(key)
  521. if current is None or candidate["score"] > current["score"]:
  522. candidates[key] = candidate
  523. ranked = sorted(
  524. candidates.values(),
  525. key=lambda item: (float(item.get("score") or 0), len(item.get("content") or "")),
  526. reverse=True,
  527. )
  528. return ranked[: max(top_k * 3, top_k)], mode, unavailable
  529. def _local_search_candidates(
  530. db: Session,
  531. question: str,
  532. *,
  533. top_k: int,
  534. ) -> list[dict[str, Any]]:
  535. candidates: dict[str, dict[str, Any]] = {}
  536. for keyword in list(dict.fromkeys([question, *extract_query_terms(question)]))[:4]:
  537. items, _ = knowledge_repo.list_knowledge(
  538. db,
  539. keyword=keyword,
  540. page=1,
  541. page_size=top_k,
  542. )
  543. for item in items:
  544. score = 0.0
  545. if keyword and keyword.casefold() in item.title.casefold():
  546. score = 0.8
  547. elif keyword and keyword.casefold() in (item.summary or "").casefold():
  548. score = 0.65
  549. elif keyword and keyword.casefold() in (item.content or "").casefold():
  550. score = 0.5
  551. candidate = {
  552. "chunk_id": item.id,
  553. "note_id": item.id,
  554. "title": item.title,
  555. "file_path": item.source_ref,
  556. "content": clean_knowledge_text(item.content or item.summary or ""),
  557. "heading": item.category,
  558. "score": score,
  559. "category": "other",
  560. "category_name": item.category or "其他",
  561. }
  562. current = candidates.get(item.id)
  563. if current is None or candidate["score"] > current["score"]:
  564. candidates[item.id] = candidate
  565. return sorted(
  566. candidates.values(),
  567. key=lambda item: float(item.get("score") or 0),
  568. reverse=True,
  569. )[:top_k]
  570. def clean_knowledge_text(value: str) -> str:
  571. text = str(value or "")
  572. text = re.sub(r"---\s*PAGE\s*\d+\s*---", "\n", text, flags=re.I)
  573. text = re.sub(r"\[\[([^|\]]+)\|([^\]]+)\]\]", r"\2", text)
  574. text = re.sub(r"\[\[([^\]]+)\]\]", r"\1", text)
  575. text = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", text)
  576. text = re.sub(r"\s+(?=[,。;:!?、])", "", text)
  577. text = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])", "", text)
  578. text = re.sub(r"[ \t]+", " ", text)
  579. text = re.sub(r"\n{3,}", "\n\n", text)
  580. return text.strip()
  581. def _split_sentences(text: str) -> list[str]:
  582. parts = re.split(r"(?<=[。!?;])|\n+", clean_knowledge_text(text))
  583. return [part.strip(" -;\t\r\n") for part in parts if len(part.strip()) >= 8]
  584. def _rank_sentences(
  585. text: str,
  586. terms: list[str],
  587. *,
  588. formula_mode: bool = False,
  589. ) -> list[str]:
  590. ranked: list[tuple[float, str]] = []
  591. for sentence in _split_sentences(text):
  592. score = 0.0
  593. score += sum(2.5 for term in terms if term and term in sentence)
  594. score += sum(1.5 for word in ACTION_WORDS if word in sentence)
  595. score += min(len(sentence) / 80, 1.2)
  596. if formula_mode:
  597. score += 2.5 * len(
  598. re.findall(
  599. r"公式|式\s*[((]?\d+|m3/min|m³/min|Q[_\u4e00-\u9fffA-Za-z]",
  600. sentence,
  601. flags=re.I,
  602. )
  603. )
  604. score += 1.2 * len(re.findall(r"\d+(?:\.\d+)?", sentence))
  605. ranked.append((score, sentence))
  606. ranked.sort(key=lambda item: item[0], reverse=True)
  607. return list(dict.fromkeys(sentence for _, sentence in ranked))
  608. def _extract_steps(answer: str, terms: list[str]) -> list[str]:
  609. steps: list[str] = []
  610. for line in answer.splitlines():
  611. cleaned = re.sub(r"^\s*(?:[-*]|\d+[.、)])\s*", "", line).strip()
  612. if len(cleaned) >= 8 and any(word in cleaned for word in ACTION_WORDS):
  613. steps.append(cleaned[:160])
  614. if len(steps) >= 2:
  615. return list(dict.fromkeys(steps))[:4]
  616. for sentence in _rank_sentences(answer, terms):
  617. if any(word in sentence for word in ACTION_WORDS):
  618. steps.append(sentence[:160])
  619. if len(steps) >= 3:
  620. break
  621. return list(dict.fromkeys(steps))
  622. def extractive_answer(candidates: list[dict[str, Any]], question: str) -> tuple[str, list[str]]:
  623. if not candidates:
  624. return (
  625. "暂未在知识库中检索到与该问题直接相关的内容,"
  626. "请补充标准编号、设备名称或业务场景后重试。",
  627. [],
  628. )
  629. terms = extract_query_terms(question)
  630. sentences: list[str] = []
  631. for candidate in candidates[:4]:
  632. sentences.extend(_rank_sentences(candidate.get("content") or "", terms)[:3])
  633. sentences = list(dict.fromkeys(sentences))[:5]
  634. if not sentences:
  635. sentences = [str(candidates[0].get("content") or "")[:300]]
  636. answer = "".join(sentences)[:900]
  637. return answer, _extract_steps(answer, terms)
  638. def _build_rag_context(
  639. question: str,
  640. candidates: list[dict[str, Any]],
  641. ) -> tuple[str, int]:
  642. terms = extract_query_terms(question)
  643. formula_mode = any(word in question for word in ("公式", "计算", "参数", "系数", "方法"))
  644. max_total = max(settings.knowledge_rag_context_max_chars, 2000)
  645. max_source = max(settings.knowledge_rag_source_max_chars, 500)
  646. blocks: list[str] = []
  647. total_chars = 0
  648. for index, item in enumerate(candidates):
  649. content = clean_knowledge_text(str(item.get("content") or ""))
  650. ranked = _rank_sentences(content, terms, formula_mode=formula_mode)
  651. source_text = "".join(ranked[:20])[:max_source] or content[:max_source]
  652. block = (
  653. f"[{index + 1}] 标题:{item.get('title') or item.get('note_id')}\n"
  654. f"来源:{item.get('file_path') or item.get('note_id')}\n"
  655. f"内容:{source_text}"
  656. )
  657. remaining = max_total - total_chars
  658. if remaining < 300:
  659. break
  660. if len(block) > remaining:
  661. block = block[:remaining]
  662. blocks.append(block)
  663. total_chars += len(block)
  664. return "\n\n".join(blocks), total_chars
  665. def _section_tuple(value: str) -> tuple[int, ...]:
  666. return tuple(int(part) for part in value.split("."))
  667. def _extract_section_ranges(text: str) -> list[tuple[str, str]]:
  668. ranges = [
  669. (match.group(1), match.group(2))
  670. for match in SECTION_RANGE_RE.finditer(text)
  671. ]
  672. if ranges:
  673. return list(dict.fromkeys(ranges))[:4]
  674. numbers = list(dict.fromkeys(SECTION_NUMBER_RE.findall(text)))
  675. return [(number, number) for number in numbers[:4]]
  676. def _extract_section_blocks(text: str, start_ref: str, end_ref: str) -> str:
  677. content = clean_knowledge_text(text)
  678. start_tuple = _section_tuple(start_ref)
  679. end_tuple = _section_tuple(end_ref)
  680. selected: list[str] = []
  681. in_range = False
  682. for line in content.splitlines():
  683. heading = SECTION_HEADING_RE.match(line)
  684. if heading:
  685. current_tuple = _section_tuple(heading.group(1))
  686. if not in_range and start_tuple <= current_tuple <= end_tuple:
  687. in_range = True
  688. elif in_range and current_tuple > end_tuple:
  689. break
  690. if in_range:
  691. selected.append(line)
  692. if selected:
  693. return "\n".join(selected)[:8000]
  694. start_index = content.find(start_ref)
  695. if start_index < 0:
  696. return ""
  697. return content[start_index : start_index + 3000]
  698. def _ranges_for_anchor(
  699. anchor_content: str,
  700. full_content: str,
  701. ) -> list[tuple[str, str]]:
  702. anchor_numbers = [
  703. _section_tuple(number)
  704. for number in SECTION_NUMBER_RE.findall(anchor_content)
  705. ]
  706. if not anchor_numbers:
  707. return _extract_section_ranges(anchor_content)
  708. matched: list[tuple[str, str]] = []
  709. for match in SECTION_RANGE_RE.finditer(full_content):
  710. start_ref, end_ref = match.group(1), match.group(2)
  711. start_tuple = _section_tuple(start_ref)
  712. end_tuple = _section_tuple(end_ref)
  713. for number in anchor_numbers:
  714. if start_tuple <= number <= end_tuple:
  715. matched.append((start_ref, end_ref))
  716. break
  717. if number[:2] == start_tuple[:2] == end_tuple[:2]:
  718. matched.append((start_ref, end_ref))
  719. break
  720. if matched:
  721. return list(dict.fromkeys(matched))[:4]
  722. return _extract_section_ranges(anchor_content)
  723. async def _expand_section_context(
  724. candidates: list[dict[str, Any]],
  725. ) -> list[dict[str, Any]]:
  726. references: dict[str, str] = {}
  727. for item in candidates:
  728. note_id = str(item.get("note_id") or "")
  729. if not note_id:
  730. continue
  731. content = str(item.get("content") or "")
  732. if _extract_section_ranges(content) or SECTION_NUMBER_RE.search(content):
  733. references[note_id] = content
  734. if not references:
  735. return candidates
  736. client = get_knowledge_hub_client()
  737. note_ids = list(references)
  738. payloads = await asyncio.gather(
  739. *[client.note_full(note_id) for note_id in note_ids],
  740. return_exceptions=True,
  741. )
  742. expanded_blocks: dict[str, str] = {}
  743. for note_id, payload in zip(note_ids, payloads, strict=False):
  744. if isinstance(payload, Exception):
  745. continue
  746. full_content = str(payload.get("full_content") or "")
  747. ranges = _ranges_for_anchor(references[note_id], full_content)
  748. blocks = [
  749. _extract_section_blocks(full_content, start_ref, end_ref)
  750. for start_ref, end_ref in ranges
  751. ]
  752. content = "\n\n".join(block for block in blocks if block)
  753. if content:
  754. expanded_blocks[note_id] = content[:8000]
  755. if not expanded_blocks:
  756. return candidates
  757. expanded: list[dict[str, Any]] = []
  758. for item in candidates:
  759. note_id = str(item.get("note_id") or "")
  760. section_content = expanded_blocks.get(note_id)
  761. if not section_content:
  762. expanded.append(item)
  763. continue
  764. expanded.append(
  765. {
  766. **item,
  767. "content": (
  768. f"{item.get('content') or ''}\n\n"
  769. f"[章节展开]\n{section_content}"
  770. ),
  771. "heading": f"{item.get('heading') or ''} · 条款展开",
  772. }
  773. )
  774. return expanded
  775. async def _generated_answer(
  776. question: str,
  777. candidates: list[dict[str, Any]],
  778. ) -> tuple[str, list[str], str, int] | None:
  779. if not candidates:
  780. return None
  781. context, context_chars = _build_rag_context(question, candidates)
  782. if not context:
  783. return None
  784. prompt = f"{KNOWLEDGE_RAG_PROMPT}\n\n用户问题:{question}\n\n检索资料:\n{context}"
  785. answer = await _model_text(
  786. prompt,
  787. system_prompt=KNOWLEDGE_RAG_SYSTEM_PROMPT,
  788. )
  789. if not answer:
  790. return None
  791. return (
  792. answer,
  793. _extract_steps(answer, extract_query_terms(question)),
  794. settings.deepagent_model,
  795. context_chars,
  796. )
  797. def is_conversational_question(question: str) -> bool:
  798. normalized = re.sub(r"[\s,。!?!?,.、]+", "", question).lower()
  799. if not normalized or len(normalized) > 24:
  800. return False
  801. if any(term in normalized for term in DOMAIN_TERMS):
  802. return False
  803. return any(term in normalized for term in CONVERSATIONAL_TERMS)
  804. async def _conversational_answer(question: str) -> tuple[str, str] | None:
  805. answer = await _model_text(
  806. f"{KNOWLEDGE_ASSISTANT_CHAT_PROMPT}\n\n用户:{question}",
  807. system_prompt=KNOWLEDGE_ASSISTANT_CHAT_SYSTEM_PROMPT,
  808. )
  809. if not answer:
  810. return None
  811. return answer, settings.deepagent_model
  812. def _conversational_fallback() -> str:
  813. return (
  814. "你好,我是通防知识助手。你可以询问规程标准、通风、瓦斯、防灭火、"
  815. "防尘、监测监控或隐患处置等通防业务问题。"
  816. )
  817. def _sources_from_candidates(
  818. candidates: list[dict[str, Any]],
  819. ) -> list[KnowledgeSource]:
  820. return [
  821. KnowledgeSource(
  822. citation_index=index + 1,
  823. note_id=item["note_id"],
  824. title=item["title"],
  825. file_path=item["file_path"],
  826. score=round(float(item["score"] or 0), 4),
  827. heading=item["heading"],
  828. snippet=item["content"][:320],
  829. category=item["category"],
  830. category_name=item["category_name"],
  831. )
  832. for index, item in enumerate(candidates)
  833. ]
  834. async def _prepare_business_answer(
  835. db: Session,
  836. payload: KnowledgeAskRequest,
  837. ) -> dict[str, Any]:
  838. requested_mode = payload.search_mode or settings.knowledge_search_mode
  839. retrieval_started = time.perf_counter()
  840. candidates, mode, unavailable = await search_candidates(
  841. payload.question,
  842. requested_mode=requested_mode,
  843. top_k=payload.top_k,
  844. tag=payload.tag,
  845. )
  846. if payload.category:
  847. candidates = [
  848. item
  849. for item in candidates
  850. if classify_note({"note_id": item["note_id"], "title": item["title"]})[0]
  851. == payload.category
  852. ]
  853. degraded = unavailable
  854. if not candidates:
  855. candidates = _local_search_candidates(
  856. db,
  857. payload.question,
  858. top_k=payload.top_k,
  859. )
  860. degraded = bool(candidates)
  861. if payload.generate_answer:
  862. candidates = await _expand_section_context(candidates)
  863. candidates.sort(
  864. key=lambda item: (
  865. "[章节展开]" in str(item.get("content") or ""),
  866. float(item.get("score") or 0),
  867. ),
  868. reverse=True,
  869. )
  870. return {
  871. "requested_mode": requested_mode,
  872. "search_mode": mode,
  873. "degraded": degraded,
  874. "candidates": candidates,
  875. "context_candidates": candidates[: payload.top_k],
  876. "retrieval_ms": round((time.perf_counter() - retrieval_started) * 1000, 1),
  877. }
  878. async def _model_text(
  879. prompt: str,
  880. *,
  881. system_prompt: str = KNOWLEDGE_RAG_SYSTEM_PROMPT,
  882. ) -> str:
  883. if not settings.model_configured:
  884. return ""
  885. from openai import AsyncOpenAI
  886. client = AsyncOpenAI(
  887. api_key=settings.openai_api_key,
  888. base_url=settings.openai_base_url or None,
  889. timeout=settings.openai_timeout,
  890. )
  891. response = await client.chat.completions.create(
  892. model=settings.deepagent_model,
  893. messages=[
  894. {"role": "system", "content": system_prompt},
  895. {"role": "user", "content": prompt},
  896. ],
  897. stream=False,
  898. )
  899. if not response.choices:
  900. return ""
  901. return str(response.choices[0].message.content or "").strip()
  902. async def _stream_model_deltas(
  903. prompt: str,
  904. *,
  905. system_prompt: str = KNOWLEDGE_RAG_SYSTEM_PROMPT,
  906. ) -> AsyncIterator[tuple[str, str]]:
  907. if not settings.model_configured:
  908. return
  909. from openai import AsyncOpenAI
  910. client = AsyncOpenAI(
  911. api_key=settings.openai_api_key,
  912. base_url=settings.openai_base_url or None,
  913. timeout=settings.openai_timeout,
  914. )
  915. stream = await client.chat.completions.create(
  916. model=settings.deepagent_model,
  917. messages=[
  918. {"role": "system", "content": system_prompt},
  919. {"role": "user", "content": prompt},
  920. ],
  921. stream=True,
  922. )
  923. try:
  924. async for chunk in stream:
  925. if not chunk.choices:
  926. continue
  927. delta = chunk.choices[0].delta
  928. reasoning = getattr(delta, "reasoning_content", None)
  929. if reasoning:
  930. yield "thinking", str(reasoning)
  931. content = delta.content
  932. if content:
  933. yield "answer", str(content)
  934. finally:
  935. await stream.close()
  936. async def stream_answer(
  937. db: Session,
  938. payload: KnowledgeAskRequest,
  939. ) -> AsyncIterator[dict[str, Any]]:
  940. started = time.perf_counter()
  941. if is_conversational_question(payload.question):
  942. yield {
  943. "event": "meta",
  944. "data": {
  945. "question": payload.question,
  946. "answer_status": "conversational",
  947. "search_mode": "conversation",
  948. "sources": [],
  949. },
  950. }
  951. answer = ""
  952. generation_started = time.perf_counter()
  953. if payload.generate_answer:
  954. try:
  955. async for event_type, delta in _stream_model_deltas(
  956. f"{KNOWLEDGE_ASSISTANT_CHAT_PROMPT}\n\n用户:{payload.question}",
  957. system_prompt=KNOWLEDGE_ASSISTANT_CHAT_SYSTEM_PROMPT,
  958. ):
  959. if event_type == "thinking":
  960. yield {"event": "thinking", "data": {"content": delta}}
  961. else:
  962. answer += delta
  963. yield {"event": "delta", "data": {"content": delta}}
  964. except Exception as exc:
  965. yield {
  966. "event": "warning",
  967. "data": {"message": f"大模型流式生成失败:{type(exc).__name__}"},
  968. }
  969. if not answer:
  970. answer = _conversational_fallback()
  971. yield {"event": "delta", "data": {"content": answer}}
  972. yield {
  973. "event": "done",
  974. "data": {
  975. "answer": answer,
  976. "answer_status": "conversational",
  977. "answer_model": settings.deepagent_model if payload.generate_answer else "",
  978. "sources": [],
  979. "steps": [],
  980. "generation_ms": round((time.perf_counter() - generation_started) * 1000, 1),
  981. "latency_ms": round((time.perf_counter() - started) * 1000, 1),
  982. },
  983. }
  984. return
  985. prepared = await _prepare_business_answer(db, payload)
  986. candidates = prepared["candidates"]
  987. context_candidates = prepared["context_candidates"]
  988. sources = _sources_from_candidates(context_candidates)
  989. yield {
  990. "event": "meta",
  991. "data": {
  992. "question": payload.question,
  993. "answer_status": "generating" if payload.generate_answer else "extractive",
  994. "requested_mode": str(prepared["requested_mode"] or "keyword"),
  995. "search_mode": prepared["search_mode"],
  996. "retrieval_ms": prepared["retrieval_ms"],
  997. "total_candidates": len(candidates),
  998. "degraded": prepared["degraded"],
  999. "sources": [source.model_dump() for source in sources],
  1000. },
  1001. }
  1002. answer = ""
  1003. answer_model = ""
  1004. context_chars = 0
  1005. generation_started = time.perf_counter()
  1006. if payload.generate_answer and context_candidates:
  1007. context, context_chars = _build_rag_context(payload.question, context_candidates)
  1008. prompt = (
  1009. f"{KNOWLEDGE_RAG_PROMPT}\n\n用户问题:{payload.question}"
  1010. f"\n\n检索资料:\n{context}"
  1011. )
  1012. try:
  1013. async for event_type, delta in _stream_model_deltas(
  1014. prompt,
  1015. system_prompt=KNOWLEDGE_RAG_SYSTEM_PROMPT,
  1016. ):
  1017. if event_type == "thinking":
  1018. yield {"event": "thinking", "data": {"content": delta}}
  1019. else:
  1020. answer += delta
  1021. yield {"event": "delta", "data": {"content": delta}}
  1022. if answer:
  1023. answer_model = settings.deepagent_model
  1024. except Exception as exc:
  1025. yield {
  1026. "event": "warning",
  1027. "data": {"message": f"大模型流式生成失败:{type(exc).__name__}"},
  1028. }
  1029. if not answer:
  1030. answer, steps = extractive_answer(candidates, payload.question)
  1031. yield {"event": "delta", "data": {"content": answer}}
  1032. answer_status = "no_result" if not candidates else "extractive"
  1033. else:
  1034. steps = _extract_steps(answer, extract_query_terms(payload.question))
  1035. answer_status = "generated"
  1036. yield {
  1037. "event": "done",
  1038. "data": {
  1039. "answer": answer,
  1040. "answer_status": answer_status,
  1041. "answer_model": answer_model,
  1042. "sources": [source.model_dump() for source in sources],
  1043. "steps": steps,
  1044. "retrieval_ms": prepared["retrieval_ms"],
  1045. "context_chars": context_chars,
  1046. "generation_ms": round((time.perf_counter() - generation_started) * 1000, 1),
  1047. "latency_ms": round((time.perf_counter() - started) * 1000, 1),
  1048. "total_candidates": len(candidates),
  1049. "degraded": prepared["degraded"],
  1050. },
  1051. }
  1052. async def build_answer(
  1053. db: Session,
  1054. payload: KnowledgeAskRequest,
  1055. ) -> KnowledgeAskResponse:
  1056. started = time.perf_counter()
  1057. if is_conversational_question(payload.question):
  1058. generation_started = time.perf_counter()
  1059. conversational = None
  1060. if payload.generate_answer:
  1061. conversational = await _conversational_answer(payload.question)
  1062. generation_ms = round((time.perf_counter() - generation_started) * 1000, 1)
  1063. answer, answer_model = (
  1064. conversational
  1065. if conversational is not None
  1066. else (_conversational_fallback(), "")
  1067. )
  1068. return KnowledgeAskResponse(
  1069. question=payload.question,
  1070. answer=answer,
  1071. answer_status="conversational",
  1072. answer_model=answer_model,
  1073. generation_enabled=payload.generate_answer,
  1074. requested_mode="conversation",
  1075. search_mode="conversation",
  1076. generation_ms=generation_ms,
  1077. latency_ms=round((time.perf_counter() - started) * 1000, 1),
  1078. )
  1079. prepared = await _prepare_business_answer(db, payload)
  1080. candidates = prepared["candidates"]
  1081. mode = prepared["search_mode"]
  1082. degraded = prepared["degraded"]
  1083. retrieval_ms = prepared["retrieval_ms"]
  1084. answer_model = ""
  1085. answer: str
  1086. steps: list[str]
  1087. answer_status = "extractive"
  1088. generation_ms = 0.0
  1089. context_chars = 0
  1090. context_candidates = prepared["context_candidates"]
  1091. if payload.generate_answer:
  1092. generation_started = time.perf_counter()
  1093. generated = await _generated_answer(payload.question, context_candidates)
  1094. generation_ms = round((time.perf_counter() - generation_started) * 1000, 1)
  1095. if generated is None:
  1096. answer, steps = extractive_answer(candidates, payload.question)
  1097. else:
  1098. answer, steps, answer_model, context_chars = generated
  1099. answer_status = "generated"
  1100. else:
  1101. answer, steps = extractive_answer(candidates, payload.question)
  1102. if not candidates:
  1103. answer_status = "no_result"
  1104. sources = _sources_from_candidates(context_candidates)
  1105. return KnowledgeAskResponse(
  1106. question=payload.question,
  1107. answer=answer,
  1108. answer_status=answer_status,
  1109. answer_model=answer_model,
  1110. generation_enabled=payload.generate_answer,
  1111. steps=steps,
  1112. sources=sources,
  1113. requested_mode=str(prepared["requested_mode"] or "keyword"),
  1114. search_mode=mode,
  1115. retrieval_ms=retrieval_ms,
  1116. generation_ms=generation_ms,
  1117. context_chars=context_chars,
  1118. latency_ms=round((time.perf_counter() - started) * 1000, 1),
  1119. total_candidates=len(candidates),
  1120. degraded=degraded,
  1121. )
  1122. def _matching_notes(
  1123. notes: list[dict[str, Any]],
  1124. *,
  1125. tags: set[str],
  1126. keywords: tuple[str, ...] = (),
  1127. ) -> list[dict[str, Any]]:
  1128. matches: list[dict[str, Any]] = []
  1129. for note in notes:
  1130. note_tags = {str(tag) for tag in note.get("tags") or []}
  1131. title = str(note.get("title") or "")
  1132. note_id = str(note.get("note_id") or "")
  1133. if note_tags & tags or any(keyword in title or keyword in note_id for keyword in keywords):
  1134. matches.append(note)
  1135. return matches
  1136. async def build_graph(topic: str = "瓦斯超限风险事件") -> KnowledgeGraphResponse:
  1137. try:
  1138. notes = await fetch_catalog()
  1139. except APIError:
  1140. notes = []
  1141. groups = {
  1142. "risk_gas": _matching_notes(notes, tags={"safety/gas"}, keywords=("瓦斯",)),
  1143. "local_fan": _matching_notes(
  1144. notes,
  1145. tags={"ventilation/local-fan"},
  1146. keywords=("局部通风机", "循环风", "停风"),
  1147. ),
  1148. "door": _matching_notes(
  1149. notes,
  1150. tags={"ventilation/airflow"},
  1151. keywords=("风门", "漏风", "通风设施"),
  1152. ),
  1153. "airflow": _matching_notes(notes, tags={"ventilation/airflow"}, keywords=("风量",)),
  1154. "monitoring": _matching_notes(
  1155. notes,
  1156. tags={"safety/monitoring", "safety/监控"},
  1157. keywords=("监测监控", "传感器"),
  1158. ),
  1159. }
  1160. def samples(group: list[dict[str, Any]]) -> list[str]:
  1161. return [
  1162. str(note.get("title") or note.get("note_id") or "")
  1163. for note in group[:3]
  1164. ]
  1165. nodes = [
  1166. KnowledgeGraphNode(
  1167. id="risk_gas",
  1168. label="瓦斯超限",
  1169. sub_label="风险事件",
  1170. node_type="risk",
  1171. value=len(groups["risk_gas"]),
  1172. sample_notes=samples(groups["risk_gas"]),
  1173. x=280,
  1174. y=180,
  1175. ),
  1176. KnowledgeGraphNode(
  1177. id="local_fan",
  1178. label="局部通风机",
  1179. sub_label="停风/循环风",
  1180. node_type="cause",
  1181. value=len(groups["local_fan"]),
  1182. sample_notes=samples(groups["local_fan"]),
  1183. x=126,
  1184. y=88,
  1185. ),
  1186. KnowledgeGraphNode(
  1187. id="door",
  1188. label="风门",
  1189. sub_label="漏风/失效",
  1190. node_type="cause",
  1191. value=len(groups["door"]),
  1192. sample_notes=samples(groups["door"]),
  1193. x=438,
  1194. y=92,
  1195. ),
  1196. KnowledgeGraphNode(
  1197. id="airflow",
  1198. label="风量分配",
  1199. sub_label="风量不足",
  1200. node_type="cause",
  1201. value=len(groups["airflow"]),
  1202. sample_notes=samples(groups["airflow"]),
  1203. x=118,
  1204. y=280,
  1205. ),
  1206. KnowledgeGraphNode(
  1207. id="monitoring",
  1208. label="监测系统",
  1209. sub_label="数据异常",
  1210. node_type="evidence",
  1211. value=len(groups["monitoring"]),
  1212. sample_notes=samples(groups["monitoring"]),
  1213. x=438,
  1214. y=282,
  1215. ),
  1216. ]
  1217. edges = [
  1218. KnowledgeGraphEdge(
  1219. source="local_fan",
  1220. target="risk_gas",
  1221. relation="导致",
  1222. weight=len(groups["local_fan"]),
  1223. ),
  1224. KnowledgeGraphEdge(
  1225. source="door",
  1226. target="risk_gas",
  1227. relation="导致",
  1228. weight=len(groups["door"]),
  1229. ),
  1230. KnowledgeGraphEdge(
  1231. source="airflow",
  1232. target="risk_gas",
  1233. relation="导致",
  1234. weight=len(groups["airflow"]),
  1235. ),
  1236. KnowledgeGraphEdge(
  1237. source="monitoring",
  1238. target="risk_gas",
  1239. relation="监测",
  1240. weight=len(groups["monitoring"]),
  1241. ),
  1242. KnowledgeGraphEdge(
  1243. source="local_fan",
  1244. target="door",
  1245. relation="关联",
  1246. weight=min(len(groups["local_fan"]), len(groups["door"])),
  1247. ),
  1248. KnowledgeGraphEdge(
  1249. source="airflow",
  1250. target="monitoring",
  1251. relation="印证",
  1252. weight=min(len(groups["airflow"]), len(groups["monitoring"])),
  1253. ),
  1254. ]
  1255. return KnowledgeGraphResponse(topic=topic, nodes=nodes, edges=edges)