device.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. from __future__ import annotations
  2. import re
  3. from collections.abc import Sequence
  4. from dataclasses import dataclass
  5. from datetime import datetime
  6. from typing import Protocol
  7. from sqlalchemy import text
  8. from app.core.knowledge.access import KnowledgeAccessContext
  9. from app.core.knowledge.retrieval.contracts import KnowledgeEvidence
  10. MAX_DEVICE_QUERY_LENGTH = 300
  11. MAX_DEVICE_RESULTS = 100
  12. _EVENT_LABELS = {
  13. "fault": "故障",
  14. "alarm": "告警",
  15. "maintenance": "维护",
  16. "downtime": "停机",
  17. }
  18. _EVENT_ORDER = {
  19. event_type: position for position, event_type in enumerate(_EVENT_LABELS)
  20. }
  21. _LABELED_IDENTIFIER = re.compile(
  22. r"(?:源\s*ID|平台\s*UID)\s*(?:为|是|[::])?\s*"
  23. r"([0-9A-Za-z][0-9A-Za-z._:-]{1,})",
  24. re.IGNORECASE,
  25. )
  26. _QUESTION_ENTITY_PATTERNS = tuple(
  27. re.compile(pattern)
  28. for pattern in (
  29. r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
  30. r"(?:的)?(?:责任人|负责人)(?:是)?谁[??。]?$",
  31. r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
  32. r"(?:最近)?\s*有哪些\s*(?:故障|告警|维护|停机)"
  33. r"(?:记录|事件)?[??。]?$",
  34. r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
  35. r"负责哪些设备[??。]?$",
  36. r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
  37. r"有哪些设备[??。]?$",
  38. r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
  39. r"(?:位于哪里|在哪里)[??。]?$",
  40. )
  41. )
  42. @dataclass(frozen=True)
  43. class DeviceSearchRow:
  44. asset_uid: str
  45. asset_type: str
  46. name: str
  47. current_version: int
  48. location: str | None
  49. organization: str | None
  50. responsible_person: str | None
  51. source_codes: tuple[str, ...]
  52. related_events: tuple[tuple[str, str, str | None], ...]
  53. business_domain_uid: str | None
  54. updated_at: datetime | None
  55. rank: float
  56. class DeviceKnowledgeRepository(Protocol):
  57. def search(
  58. self,
  59. *,
  60. query: str,
  61. global_access: bool,
  62. business_domain_uids: tuple[str, ...],
  63. limit: int,
  64. ) -> Sequence[DeviceSearchRow]: ...
  65. def normalize_device_query(query: str) -> str:
  66. normalized = query.strip()
  67. if not normalized:
  68. raise ValueError("设备检索词不能为空")
  69. if len(normalized) > MAX_DEVICE_QUERY_LENGTH:
  70. raise ValueError(
  71. f"设备检索词长度不能超过 {MAX_DEVICE_QUERY_LENGTH} 个字符"
  72. )
  73. return normalized
  74. def device_query_terms(query: str) -> tuple[str, ...]:
  75. normalized = normalize_device_query(query)
  76. candidates: list[str] = []
  77. identifier = _LABELED_IDENTIFIER.search(normalized)
  78. if identifier:
  79. candidates.append(identifier.group(1))
  80. for pattern in _QUESTION_ENTITY_PATTERNS:
  81. match = pattern.match(normalized)
  82. if match:
  83. candidate = match.group(1).strip(" \t\r\n,,。;;::")
  84. if len(candidate) >= 2:
  85. candidates.append(candidate)
  86. break
  87. candidates.append(normalized)
  88. return tuple(dict.fromkeys(candidates))
  89. def _clean_values(values: Sequence[str]) -> tuple[str, ...]:
  90. return tuple(sorted({value.strip() for value in values if value.strip()}))
  91. def _clean_events(
  92. values: Sequence[tuple[str, str, str | None]],
  93. ) -> tuple[tuple[str, str, str | None], ...]:
  94. normalized = {
  95. (
  96. event_type.strip().lower(),
  97. title.strip(),
  98. source_code.strip() or None if source_code else None,
  99. )
  100. for event_type, title, source_code in values
  101. if event_type.strip().lower() in _EVENT_LABELS and title.strip()
  102. }
  103. return tuple(
  104. sorted(
  105. normalized,
  106. key=lambda item: (
  107. _EVENT_ORDER[item[0]],
  108. item[1],
  109. item[2] or "",
  110. ),
  111. )
  112. )
  113. def build_device_evidence(row: DeviceSearchRow) -> KnowledgeEvidence:
  114. source_codes = _clean_values(row.source_codes)
  115. events = _clean_events(row.related_events)
  116. lines = [
  117. f"设备名称:{row.name}",
  118. f"平台 UID:{row.asset_uid}",
  119. ]
  120. if source_codes:
  121. lines.append(f"源 ID:{'、'.join(source_codes)}")
  122. if row.location:
  123. lines.append(f"位置:{row.location}")
  124. if row.organization:
  125. lines.append(f"所属组织:{row.organization}")
  126. if row.responsible_person:
  127. lines.append(f"责任人:{row.responsible_person}")
  128. for event_type, title, source_code in events:
  129. detail = f"{title}({source_code})" if source_code else title
  130. lines.append(f"{_EVENT_LABELS[event_type]}:{detail}")
  131. point_key = f"DeviceAsset/{row.asset_uid}/summary"
  132. return KnowledgeEvidence(
  133. chunk_id=f"device:{row.asset_uid}:v{row.current_version}",
  134. content="\n".join(lines),
  135. score=float(row.rank),
  136. retriever="device",
  137. object_uid=row.asset_uid,
  138. object_type="DeviceAsset",
  139. object_version=row.current_version,
  140. business_domain_uid=row.business_domain_uid,
  141. point_keys=(point_key,),
  142. point_revisions=(row.current_version,),
  143. generation=row.current_version,
  144. source_updated_at=(
  145. row.updated_at.isoformat() if row.updated_at is not None else None
  146. ),
  147. )
  148. class SqlDeviceKnowledgeRetriever:
  149. def __init__(self, repository: DeviceKnowledgeRepository):
  150. self._repository = repository
  151. def retrieve(
  152. self,
  153. query: str,
  154. context: KnowledgeAccessContext,
  155. limit: int,
  156. ) -> tuple[KnowledgeEvidence, ...]:
  157. normalized_query = normalize_device_query(query)
  158. bounded_limit = max(1, min(int(limit), MAX_DEVICE_RESULTS))
  159. rows = self._repository.search(
  160. query=normalized_query,
  161. global_access=context.global_access,
  162. business_domain_uids=tuple(sorted(context.business_domain_uids)),
  163. limit=bounded_limit,
  164. )
  165. return tuple(build_device_evidence(row) for row in rows)
  166. _AUTHORIZED_DEVICE_CTES = """
  167. WITH authorized_sources AS (
  168. SELECT source.uid,
  169. CASE
  170. WHEN :global_access THEN NULL::uuid
  171. ELSE MIN(scope.domain_uid)::uuid
  172. END AS business_domain_uid
  173. FROM public.ingestion_sources source
  174. LEFT JOIN LATERAL (
  175. SELECT value AS domain_uid
  176. FROM jsonb_array_elements_text(
  177. CASE
  178. WHEN jsonb_typeof(
  179. source.permission_scope -> 'business_domains'
  180. ) = 'array'
  181. THEN source.permission_scope -> 'business_domains'
  182. ELSE '[]'::jsonb
  183. END
  184. ) values(value)
  185. ) scope ON TRUE
  186. WHERE source.status = 'active'
  187. AND (
  188. :global_access
  189. OR scope.domain_uid = ANY(CAST(:domains AS text[]))
  190. )
  191. GROUP BY source.uid
  192. ),
  193. authorized_mappings AS (
  194. SELECT mapping.asset_uid,
  195. MIN(
  196. authorized.business_domain_uid::text
  197. )::uuid AS business_domain_uid,
  198. array_agg(
  199. DISTINCT mapping.source_code
  200. ORDER BY mapping.source_code
  201. ) AS source_codes,
  202. MAX(
  203. COALESCE(
  204. mapping.source_updated_at,
  205. mapping.last_seen_at
  206. )
  207. ) AS source_updated_at
  208. FROM public.device_asset_source_mappings mapping
  209. JOIN authorized_sources authorized
  210. ON authorized.uid = mapping.source_uid
  211. GROUP BY mapping.asset_uid
  212. ),
  213. authorized_events AS (
  214. SELECT event.asset_uid,
  215. jsonb_agg(
  216. DISTINCT jsonb_build_object(
  217. 'event_type', event.event_type,
  218. 'title', event.title,
  219. 'source_code', event.source_code
  220. )
  221. ) AS related_events,
  222. MAX(event.occurred_at) AS event_updated_at
  223. FROM public.device_operational_events event
  224. JOIN authorized_sources authorized
  225. ON authorized.uid = event.source_uid
  226. GROUP BY event.asset_uid
  227. )
  228. """
  229. _DEVICE_SELECT = """
  230. SELECT asset.uid AS asset_uid,
  231. asset.asset_type,
  232. asset.name,
  233. asset.current_version,
  234. asset.location,
  235. asset.organization,
  236. asset.responsible_person,
  237. mapping.source_codes,
  238. COALESCE(events.related_events, '[]'::jsonb) AS related_events,
  239. mapping.business_domain_uid,
  240. GREATEST(
  241. asset.updated_at,
  242. mapping.source_updated_at,
  243. events.event_updated_at
  244. ) AS updated_at,
  245. {rank} AS rank
  246. FROM public.device_assets asset
  247. JOIN authorized_mappings mapping
  248. ON mapping.asset_uid = asset.uid
  249. LEFT JOIN authorized_events events
  250. ON events.asset_uid = asset.uid
  251. WHERE asset.status = 'active'
  252. {predicate}
  253. """
  254. def _device_search_row(row) -> DeviceSearchRow:
  255. events = tuple(
  256. (
  257. str(event["event_type"]),
  258. str(event["title"]),
  259. (
  260. str(event["source_code"])
  261. if event.get("source_code") is not None
  262. else None
  263. ),
  264. )
  265. for event in (row["related_events"] or ())
  266. )
  267. return DeviceSearchRow(
  268. asset_uid=str(row["asset_uid"]),
  269. asset_type=str(row["asset_type"]),
  270. name=str(row["name"]),
  271. current_version=int(row["current_version"]),
  272. location=row["location"],
  273. organization=row["organization"],
  274. responsible_person=row["responsible_person"],
  275. source_codes=tuple(str(value) for value in row["source_codes"]),
  276. related_events=_clean_events(events),
  277. business_domain_uid=(
  278. str(row["business_domain_uid"])
  279. if row["business_domain_uid"] is not None
  280. else None
  281. ),
  282. updated_at=row["updated_at"],
  283. rank=float(row["rank"]),
  284. )
  285. class SqlDeviceKnowledgeRepository:
  286. def __init__(self, session):
  287. self._session = session
  288. @staticmethod
  289. def _access_parameters(
  290. *,
  291. global_access: bool,
  292. business_domain_uids: tuple[str, ...],
  293. ) -> dict[str, object]:
  294. return {
  295. "global_access": bool(global_access),
  296. "domains": list(business_domain_uids),
  297. }
  298. def search(
  299. self,
  300. *,
  301. query: str,
  302. global_access: bool,
  303. business_domain_uids: tuple[str, ...],
  304. limit: int,
  305. ) -> tuple[DeviceSearchRow, ...]:
  306. statement = text(
  307. _AUTHORIZED_DEVICE_CTES
  308. + _DEVICE_SELECT.format(
  309. rank="""
  310. CASE
  311. WHEN EXISTS (
  312. SELECT 1
  313. FROM unnest(CAST(:terms AS text[])) term(value)
  314. WHERE lower(asset.uid::text) = lower(term.value)
  315. ) THEN 1.0
  316. WHEN EXISTS (
  317. SELECT 1
  318. FROM unnest(CAST(:terms AS text[])) term(value)
  319. WHERE lower(asset.name) = lower(term.value)
  320. ) THEN 0.98
  321. WHEN EXISTS (
  322. SELECT 1
  323. FROM unnest(CAST(:terms AS text[])) term(value)
  324. JOIN unnest(mapping.source_codes) code
  325. ON lower(code) = lower(term.value)
  326. ) THEN 0.96
  327. WHEN EXISTS (
  328. SELECT 1
  329. FROM unnest(CAST(:terms AS text[])) term(value)
  330. WHERE left(
  331. lower(asset.name),
  332. char_length(term.value)
  333. ) = lower(term.value)
  334. ) THEN 0.90
  335. ELSE 0.80
  336. END
  337. """,
  338. predicate="""
  339. AND EXISTS (
  340. SELECT 1
  341. FROM unnest(CAST(:terms AS text[])) term(value)
  342. WHERE
  343. strpos(
  344. lower(asset.uid::text),
  345. lower(term.value)
  346. ) > 0
  347. OR strpos(
  348. lower(asset.name),
  349. lower(term.value)
  350. ) > 0
  351. OR strpos(
  352. lower(COALESCE(asset.location, '')),
  353. lower(term.value)
  354. ) > 0
  355. OR strpos(
  356. lower(COALESCE(asset.organization, '')),
  357. lower(term.value)
  358. ) > 0
  359. OR strpos(
  360. lower(COALESCE(asset.responsible_person, '')),
  361. lower(term.value)
  362. ) > 0
  363. OR EXISTS (
  364. SELECT 1
  365. FROM unnest(mapping.source_codes) code
  366. WHERE strpos(
  367. lower(code),
  368. lower(term.value)
  369. ) > 0
  370. )
  371. OR EXISTS (
  372. SELECT 1
  373. FROM jsonb_array_elements(
  374. COALESCE(
  375. events.related_events,
  376. '[]'::jsonb
  377. )
  378. ) event
  379. WHERE strpos(
  380. lower(COALESCE(event ->> 'title', '')),
  381. lower(term.value)
  382. ) > 0
  383. OR strpos(
  384. lower(
  385. COALESCE(
  386. event ->> 'source_code',
  387. ''
  388. )
  389. ),
  390. lower(term.value)
  391. ) > 0
  392. )
  393. )
  394. ORDER BY rank DESC, asset.updated_at DESC, asset.uid
  395. LIMIT :limit
  396. """,
  397. )
  398. )
  399. parameters = self._access_parameters(
  400. global_access=global_access,
  401. business_domain_uids=business_domain_uids,
  402. )
  403. parameters.update(
  404. {
  405. "terms": list(device_query_terms(query)),
  406. "limit": max(1, min(int(limit), MAX_DEVICE_RESULTS)),
  407. }
  408. )
  409. rows = self._session.execute(statement, parameters).mappings()
  410. return tuple(_device_search_row(row) for row in rows)
  411. def get_detail(
  412. self,
  413. asset_uid: str,
  414. *,
  415. global_access: bool,
  416. business_domain_uids: tuple[str, ...],
  417. ) -> DeviceSearchRow | None:
  418. statement = text(
  419. _AUTHORIZED_DEVICE_CTES
  420. + _DEVICE_SELECT.format(
  421. rank="1.0",
  422. predicate="""
  423. AND asset.uid = CAST(:asset_uid AS uuid)
  424. LIMIT 1
  425. """,
  426. )
  427. )
  428. parameters = self._access_parameters(
  429. global_access=global_access,
  430. business_domain_uids=business_domain_uids,
  431. )
  432. parameters["asset_uid"] = str(asset_uid)
  433. row = self._session.execute(statement, parameters).mappings().first()
  434. return _device_search_row(row) if row is not None else None