routes.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. from __future__ import annotations
  2. import uuid
  3. from time import perf_counter
  4. from flask import current_app, g, jsonify, request
  5. from sqlalchemy import text
  6. from app import db
  7. from app.api.knowledge_base import bp
  8. from app.core.knowledge.access import build_access_context
  9. from app.core.knowledge.admin import (
  10. change_set_detail,
  11. knowledge_status,
  12. list_change_sets,
  13. retry_change_set,
  14. rollback_change_set,
  15. )
  16. from app.core.knowledge.audit import run_canonical_audit
  17. from app.core.knowledge.device_scope import (
  18. DeviceSourceScopeError,
  19. DeviceSourceScopeService,
  20. SqlAlchemyDeviceSourceScopeRepository,
  21. source_scope_access,
  22. )
  23. from app.core.knowledge.qa import AnswerSynthesizer, DeepSeekAnswerModel
  24. from app.core.knowledge.query_audit import (
  25. SqlKnowledgeQueryAuditRepository,
  26. build_query_audit,
  27. )
  28. from app.core.knowledge.retrieval.device import (
  29. SqlDeviceKnowledgeRepository,
  30. SqlDeviceKnowledgeRetriever,
  31. )
  32. from app.core.knowledge.retrieval.pipeline import KnowledgeRetrievalPipeline
  33. from app.core.knowledge.retrieval.sql import (
  34. SqlLexicalRetriever,
  35. SqlVectorRetriever,
  36. UnavailableVectorRetriever,
  37. )
  38. from app.models.result import failed, success
  39. def _bounded_int(value, *, field: str, default: int, lower: int, upper: int) -> int:
  40. try:
  41. parsed = int(default if value is None else value)
  42. except (TypeError, ValueError) as exc:
  43. raise ValueError(f"{field} 必须为整数") from exc
  44. return min(max(parsed, lower), upper)
  45. def _pipeline() -> KnowledgeRetrievalPipeline:
  46. configured = current_app.extensions.get("knowledge_retrieval_pipeline")
  47. if configured is not None:
  48. return configured
  49. api_key = current_app.config.get("QWEN_EMBEDDING_API_KEY", "")
  50. base_url = current_app.config.get("QWEN_EMBEDDING_BASE_URL", "")
  51. if api_key and base_url:
  52. from app.services.embedding.qwen import QwenEmbeddingClient
  53. embedder = QwenEmbeddingClient(
  54. api_key=api_key,
  55. base_url=base_url,
  56. model=current_app.config["QWEN_EMBEDDING_MODEL"],
  57. dimension=current_app.config["QWEN_EMBEDDING_DIMENSION"],
  58. )
  59. vector = SqlVectorRetriever(db.session, embedder)
  60. else:
  61. vector = UnavailableVectorRetriever()
  62. return KnowledgeRetrievalPipeline(
  63. lexical=SqlLexicalRetriever(db.session),
  64. vector=vector,
  65. device=SqlDeviceKnowledgeRetriever(_device_repository()),
  66. )
  67. def _serialize_evidence(item) -> dict:
  68. return {
  69. "chunk_id": item.chunk_id,
  70. "content": item.content,
  71. "score": item.score,
  72. "retriever": item.retriever,
  73. "object_uid": item.object_uid,
  74. "object_type": item.object_type,
  75. "object_version": item.object_version,
  76. "business_domain_uid": item.business_domain_uid,
  77. "point_keys": list(item.point_keys),
  78. "point_revisions": list(item.point_revisions),
  79. "index_generation": item.generation,
  80. "source_updated_at": item.source_updated_at,
  81. "freshness_status": item.freshness_status,
  82. "section_path": item.section_path,
  83. }
  84. def _answer_synthesizer() -> AnswerSynthesizer:
  85. configured = current_app.extensions.get("knowledge_answer_synthesizer")
  86. return configured or AnswerSynthesizer(DeepSeekAnswerModel())
  87. def _device_repository():
  88. configured = current_app.extensions.get("device_knowledge_repository")
  89. return configured or SqlDeviceKnowledgeRepository(db.session)
  90. def _scope_service():
  91. configured = current_app.extensions.get("device_source_scope_service")
  92. return configured or DeviceSourceScopeService(
  93. SqlAlchemyDeviceSourceScopeRepository(db.session),
  94. commit=db.session.commit,
  95. rollback=db.session.rollback,
  96. )
  97. def _audit_repository():
  98. configured = current_app.extensions.get(
  99. "knowledge_query_audit_repository"
  100. )
  101. return configured or SqlKnowledgeQueryAuditRepository(db.session)
  102. def _persist_query_audit(
  103. *,
  104. query,
  105. context,
  106. result,
  107. cited_points=(),
  108. started_at,
  109. ) -> bool:
  110. record = build_query_audit(
  111. query=query,
  112. context=context,
  113. mode=result.mode,
  114. evidence=result.evidence,
  115. cited_points=tuple(cited_points),
  116. degraded_components=result.degraded_components,
  117. latency_ms=int((perf_counter() - started_at) * 1000),
  118. )
  119. try:
  120. _audit_repository().record(record)
  121. return True
  122. except Exception:
  123. current_app.logger.exception("mandatory knowledge query audit failed")
  124. return False
  125. def _query(payload) -> str:
  126. query = str(payload.get("query") or "").strip()
  127. if not query:
  128. raise ValueError("query 不能为空")
  129. if len(query) > 300:
  130. raise ValueError("query 不能超过 300 个字符")
  131. return query
  132. def _request_context(payload):
  133. requested_domains = payload.get("business_domain_uids")
  134. if requested_domains is not None and not isinstance(requested_domains, list):
  135. raise ValueError("business_domain_uids 必须为数组")
  136. correlation_id = request.headers.get("X-Correlation-ID") or str(uuid.uuid4())
  137. context = build_access_context(
  138. db.session,
  139. identity=g.current_user,
  140. requested_business_domains=requested_domains,
  141. correlation_id=correlation_id,
  142. )
  143. return context, correlation_id
  144. @bp.post("/search")
  145. def search():
  146. payload = request.get_json(silent=True) or {}
  147. try:
  148. query = _query(payload)
  149. except ValueError as exc:
  150. return jsonify(failed(str(exc), code=400)), 400
  151. mode = str(payload.get("mode") or "auto")
  152. if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
  153. return jsonify(failed("不支持的检索模式", code=400)), 400
  154. try:
  155. limit = _bounded_int(
  156. payload.get("limit"), field="limit", default=20, lower=1, upper=100
  157. )
  158. context, correlation_id = _request_context(payload)
  159. except ValueError as exc:
  160. return jsonify(failed(str(exc), code=400)), 400
  161. started_at = perf_counter()
  162. result = _pipeline().search(
  163. query,
  164. context=context,
  165. mode=mode,
  166. limit=limit,
  167. )
  168. if not _persist_query_audit(
  169. query=query,
  170. context=context,
  171. result=result,
  172. started_at=started_at,
  173. ):
  174. return jsonify(failed("知识查询审计写入失败", code=503)), 503
  175. return jsonify(
  176. success(
  177. {
  178. "query": query,
  179. "mode": result.mode,
  180. "evidence": [_serialize_evidence(item) for item in result.evidence],
  181. "degraded_components": list(result.degraded_components),
  182. "correlation_id": correlation_id,
  183. }
  184. )
  185. )
  186. @bp.post("/ask")
  187. def ask():
  188. payload = request.get_json(silent=True) or {}
  189. try:
  190. query = _query(payload)
  191. except ValueError as exc:
  192. return jsonify(failed(str(exc), code=400)), 400
  193. mode = str(payload.get("mode") or "auto")
  194. if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
  195. return jsonify(failed("不支持的检索模式", code=400)), 400
  196. try:
  197. context, correlation_id = _request_context(payload)
  198. except ValueError as exc:
  199. return jsonify(failed(str(exc), code=400)), 400
  200. started_at = perf_counter()
  201. retrieval = _pipeline().search(query, context=context, mode=mode, limit=30)
  202. answer = _answer_synthesizer().answer(query, retrieval.evidence)
  203. cited_points = tuple(
  204. (citation.point_key, citation.point_revision)
  205. for citation in answer.citations
  206. )
  207. if not _persist_query_audit(
  208. query=query,
  209. context=context,
  210. result=retrieval,
  211. cited_points=cited_points,
  212. started_at=started_at,
  213. ):
  214. return jsonify(failed("知识查询审计写入失败", code=503)), 503
  215. evidence_by_chunk = {
  216. item.chunk_id: _serialize_evidence(item)
  217. for item in retrieval.evidence
  218. }
  219. citations = []
  220. for citation in answer.citations:
  221. serialized = dict(citation.__dict__)
  222. selected = evidence_by_chunk.get(citation.chunk_id, {})
  223. serialized["content"] = selected.get("content")
  224. serialized["business_domain_uid"] = selected.get(
  225. "business_domain_uid"
  226. )
  227. citations.append(serialized)
  228. return jsonify(
  229. success(
  230. {
  231. "query_id": correlation_id,
  232. "mode": retrieval.mode,
  233. "answer": answer.answer,
  234. "answer_status": answer.status,
  235. "degraded_components": list(retrieval.degraded_components),
  236. "citations": citations,
  237. "evidence": list(evidence_by_chunk.values()),
  238. "freshness_status": answer.freshness_status,
  239. }
  240. )
  241. )
  242. def _source_document(source_uid: str, version: int | None = None):
  243. context, _correlation_id = _request_context({})
  244. version_clause = (
  245. "AND d.object_version = :version"
  246. if version is not None
  247. else "AND d.status = 'active'"
  248. )
  249. row = (
  250. db.session.execute(
  251. text(
  252. f"""
  253. SELECT d.object_uid::text, d.object_type, d.object_version, d.object_name,
  254. d.business_domain_uid::text, d.content, d.source_updated_at,
  255. d.active_generation, d.status
  256. FROM public.governance_documents d
  257. WHERE d.object_uid = CAST(:uid AS uuid) {version_clause}
  258. """
  259. ),
  260. {"uid": source_uid, "version": version},
  261. )
  262. .mappings()
  263. .one_or_none()
  264. )
  265. if row is None or not context.permits_domain(row["business_domain_uid"]):
  266. return None
  267. return dict(row)
  268. @bp.get("/sources/<source_uid>")
  269. def source(source_uid: str):
  270. row = _source_document(source_uid)
  271. if row is None:
  272. context, _correlation_id = _request_context({})
  273. device = _device_repository().get_detail(
  274. source_uid,
  275. global_access=context.global_access,
  276. business_domain_uids=tuple(
  277. sorted(context.business_domain_uids)
  278. ),
  279. )
  280. if device is not None:
  281. row = {
  282. "object_uid": device.asset_uid,
  283. "object_type": "DeviceAsset",
  284. "object_version": device.current_version,
  285. "object_name": device.name,
  286. "asset_type": device.asset_type,
  287. "business_domain_uid": device.business_domain_uid,
  288. "location": device.location,
  289. "organization": device.organization,
  290. "responsible_person": device.responsible_person,
  291. "source_codes": list(device.source_codes),
  292. "related_events": [
  293. {
  294. "event_type": event_type,
  295. "title": title,
  296. "source_code": source_code,
  297. }
  298. for event_type, title, source_code in device.related_events
  299. ],
  300. "source_updated_at": (
  301. device.updated_at.isoformat()
  302. if device.updated_at is not None
  303. else None
  304. ),
  305. }
  306. if row is None:
  307. return jsonify(failed("来源不存在或无权访问", code=404)), 404
  308. return jsonify(success(row))
  309. @bp.get("/sources/<source_uid>/versions/<int:version>")
  310. def source_version(source_uid: str, version: int):
  311. row = _source_document(source_uid, version)
  312. if row is None:
  313. return jsonify(failed("来源版本不存在或无权访问", code=404)), 404
  314. return jsonify(success(row))
  315. @bp.get("/capabilities")
  316. def capabilities():
  317. return jsonify(
  318. success(
  319. {
  320. "standard_retrieval": True,
  321. "lightrag_enabled": bool(
  322. current_app.config.get("KNOWLEDGE_LIGHTRAG_ENABLED", False)
  323. ),
  324. "lightrag_shadow_only": bool(
  325. current_app.config.get("KNOWLEDGE_LIGHTRAG_SHADOW_ONLY", True)
  326. ),
  327. "answer_generation": bool(current_app.config.get("DEEPSEEK_API_KEY")),
  328. }
  329. )
  330. )
  331. @bp.get("/admin/device-sources")
  332. def admin_device_sources():
  333. return jsonify(success(list(_scope_service().list())))
  334. @bp.put("/admin/device-sources/<source_uid>/scope")
  335. def admin_device_source_scope(source_uid: str):
  336. payload = request.get_json(silent=True) or {}
  337. try:
  338. source_record = _scope_service().update(
  339. source_uid,
  340. payload,
  341. actor_is_admin="admin" in g.current_user.get("roles", ()),
  342. )
  343. except DeviceSourceScopeError as exc:
  344. return jsonify(failed(str(exc), code=exc.http_status)), exc.http_status
  345. access = source_scope_access(source_record.permission_scope)
  346. return jsonify(
  347. success(
  348. {
  349. "uid": source_record.uid,
  350. "name": source_record.name,
  351. "source_type": source_record.source_type,
  352. "status": source_record.status,
  353. **access,
  354. "updated_at": (
  355. source_record.updated_at.isoformat()
  356. if source_record.updated_at is not None
  357. else None
  358. ),
  359. }
  360. )
  361. )
  362. @bp.get("/admin/query-audits")
  363. def admin_query_audits():
  364. try:
  365. limit = _bounded_int(
  366. request.args.get("limit"),
  367. field="limit",
  368. default=100,
  369. lower=1,
  370. upper=200,
  371. )
  372. except ValueError as exc:
  373. return jsonify(failed(str(exc), code=400)), 400
  374. return jsonify(success(list(_audit_repository().list(limit=limit))))
  375. @bp.get("/admin/sync")
  376. def admin_sync():
  377. return jsonify(success(knowledge_status(db.session)))
  378. @bp.get("/admin/change-sets")
  379. def admin_change_sets():
  380. try:
  381. limit = _bounded_int(
  382. request.args.get("limit"), field="limit", default=50, lower=1, upper=200
  383. )
  384. except ValueError as exc:
  385. return jsonify(failed(str(exc), code=400)), 400
  386. return jsonify(success(list_change_sets(db.session, limit=limit)))
  387. @bp.get("/admin/change-sets/<change_set_id>")
  388. def admin_change_set(change_set_id: str):
  389. detail = change_set_detail(db.session, change_set_id)
  390. if detail is None:
  391. return jsonify(failed("change set 不存在", code=404)), 404
  392. return jsonify(success(detail))
  393. @bp.post("/admin/change-sets/<change_set_id>/retry")
  394. def admin_retry_change_set(change_set_id: str):
  395. if not retry_change_set(db.session, change_set_id):
  396. db.session.rollback()
  397. return jsonify(failed("当前状态不允许重试", code=409)), 409
  398. db.session.commit()
  399. return jsonify(success({"change_set_id": change_set_id, "status": "pending"}))
  400. @bp.post("/admin/change-sets/<change_set_id>/rollback")
  401. def admin_rollback_change_set(change_set_id: str):
  402. if not rollback_change_set(db.session, change_set_id):
  403. db.session.rollback()
  404. return jsonify(failed("没有可安全回退的上一版本", code=409)), 409
  405. db.session.commit()
  406. return jsonify(success({"change_set_id": change_set_id, "status": "rolled_back"}))
  407. @bp.post("/admin/audit")
  408. def admin_audit():
  409. payload = request.get_json(silent=True) or {}
  410. repair = payload.get("repair") is True
  411. findings = run_canonical_audit(db.session)
  412. repaired = 0
  413. if repair:
  414. repaired = db.session.execute(
  415. text(
  416. "DELETE FROM public.knowledge_cache_dependencies "
  417. "WHERE expires_at <= CURRENT_TIMESTAMP"
  418. )
  419. ).rowcount
  420. db.session.commit()
  421. return jsonify(
  422. success(
  423. {
  424. "mode": "repair" if repair else "report",
  425. "findings": [finding.__dict__ for finding in findings],
  426. "repaired_cache_dependencies": repaired,
  427. }
  428. )
  429. )
  430. @bp.post("/admin/retry-projection")
  431. def admin_retry_projection():
  432. projection_id = str(
  433. (request.get_json(silent=True) or {}).get("projection_id") or ""
  434. )
  435. if not projection_id:
  436. return jsonify(failed("projection_id 不能为空", code=400)), 400
  437. updated = db.session.execute(
  438. text(
  439. """
  440. UPDATE public.knowledge_index_projections
  441. SET status = 'pending', last_error = NULL, external_track_id = NULL,
  442. available_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
  443. WHERE id = CAST(:id AS uuid) AND status IN ('failed','unverified')
  444. """
  445. ),
  446. {"id": projection_id},
  447. ).rowcount
  448. if updated != 1:
  449. db.session.rollback()
  450. return jsonify(failed("当前投影状态不允许重试", code=409)), 409
  451. db.session.commit()
  452. return jsonify(success({"projection_id": projection_id, "status": "pending"}))
  453. @bp.get("/admin/evaluations")
  454. def admin_evaluations():
  455. rows = db.session.execute(
  456. text(
  457. """
  458. SELECT run.id::text, evaluation_set.name, run.status, run.configuration,
  459. run.started_at, run.finished_at,
  460. COUNT(result.case_id) AS case_count,
  461. COUNT(result.case_id) FILTER (WHERE result.passed) AS passed_count
  462. FROM public.knowledge_evaluation_runs run
  463. JOIN public.knowledge_evaluation_sets evaluation_set
  464. ON evaluation_set.id = run.evaluation_set_id
  465. LEFT JOIN public.knowledge_evaluation_results result ON result.run_id = run.id
  466. GROUP BY run.id, evaluation_set.name
  467. ORDER BY run.started_at DESC
  468. LIMIT 50
  469. """
  470. )
  471. ).mappings()
  472. return jsonify(success([dict(row) for row in rows]))