server.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. import logging
  2. import json
  3. import os
  4. import re
  5. from pathlib import Path
  6. from typing import Any
  7. import yaml
  8. from dotenv import load_dotenv
  9. from fastapi import FastAPI, HTTPException
  10. from pydantic import BaseModel, Field
  11. BRIDGE_DIR = Path(__file__).resolve().parent
  12. CONFIG_PATH = BRIDGE_DIR / "config.yaml"
  13. ENV_PATH = BRIDGE_DIR / ".env"
  14. DEFAULTS = {
  15. "server": {"host": "127.0.0.1", "port": 18733},
  16. "llm": {
  17. "provider": "openai",
  18. "model": "gpt-4o-mini",
  19. "base_url": "https://api.openai.com/v1",
  20. "temperature": 1.0,
  21. "timeout": 60,
  22. "generation_max_tokens": 2048,
  23. "answer_max_tokens": 4096,
  24. },
  25. "vanna": {"enabled": True},
  26. "text2cypher": {"enabled": True},
  27. "neo4j": {"uri": "bolt://127.0.0.1:7687", "username": "neo4j"},
  28. "security": {"readonly_sql": True, "readonly_cypher": True, "default_limit": 50},
  29. }
  30. ENV_MAPPINGS = {
  31. "RAG_AI_BRIDGE_HOST": ("server", "host", str),
  32. "RAG_AI_BRIDGE_PORT": ("server", "port", int),
  33. "LLM_PROVIDER": ("llm", "provider", str),
  34. "OPENAI_MODEL": ("llm", "model", str),
  35. "OPENAI_BASE_URL": ("llm", "base_url", str),
  36. "LLM_TEMPERATURE": ("llm", "temperature", float),
  37. "LLM_TIMEOUT": ("llm", "timeout", int),
  38. "LLM_GENERATION_MAX_TOKENS": ("llm", "generation_max_tokens", int),
  39. "LLM_ANSWER_MAX_TOKENS": ("llm", "answer_max_tokens", int),
  40. "VANNA_ENABLED": ("vanna", "enabled", "bool"),
  41. "TEXT2CYPHER_ENABLED": ("text2cypher", "enabled", "bool"),
  42. "NEO4J_URI": ("neo4j", "uri", str),
  43. "NEO4J_USERNAME": ("neo4j", "username", str),
  44. "READONLY_SQL": ("security", "readonly_sql", "bool"),
  45. "READONLY_CYPHER": ("security", "readonly_cypher", "bool"),
  46. "DEFAULT_LIMIT": ("security", "default_limit", int),
  47. }
  48. def _merge(base: dict, override: dict) -> dict:
  49. result = {key: value.copy() if isinstance(value, dict) else value for key, value in base.items()}
  50. for key, value in override.items():
  51. if isinstance(value, dict) and isinstance(result.get(key), dict):
  52. result[key] = _merge(result[key], value)
  53. else:
  54. result[key] = value
  55. return result
  56. def _convert(value: str, converter):
  57. if converter == "bool":
  58. return value.strip().lower() in {"1", "true", "yes", "on"}
  59. return converter(value)
  60. def load_settings() -> dict:
  61. file_config = {}
  62. if CONFIG_PATH.exists():
  63. with CONFIG_PATH.open("r", encoding="utf-8") as stream:
  64. file_config = yaml.safe_load(stream) or {}
  65. if not isinstance(file_config, dict):
  66. raise ValueError("config.yaml root must be a mapping")
  67. else:
  68. logging.warning("config.yaml not found; using defaults. Copy config.example.yaml to config.yaml to customize.")
  69. # override=False 保证操作系统环境变量优先于 .env。
  70. load_dotenv(ENV_PATH, override=False)
  71. settings = _merge(DEFAULTS, file_config)
  72. for env_name, (section, key, converter) in ENV_MAPPINGS.items():
  73. value = os.getenv(env_name)
  74. if value is not None and value != "":
  75. settings.setdefault(section, {})[key] = _convert(value, converter)
  76. return settings
  77. SETTINGS = load_settings()
  78. app = FastAPI(title="RAG AI Bridge", version="1.1.0")
  79. class Text2SqlRequest(BaseModel):
  80. query: str = Field(min_length=1)
  81. datasourceId: int
  82. dialect: str = "mysql"
  83. ddl: str = ""
  84. documentation: str = ""
  85. examples: list[Any] = Field(default_factory=list)
  86. tableWhitelist: list[str] = Field(default_factory=list)
  87. maxRows: int = Field(default=SETTINGS["security"]["default_limit"], ge=1, le=1000)
  88. entityMentions: list[str] = Field(default_factory=list)
  89. class Text2CypherRequest(BaseModel):
  90. query: str = Field(min_length=1)
  91. graphSourceId: int
  92. schema: str = ""
  93. examples: list[Any] = Field(default_factory=list)
  94. allowedLabels: list[str] = Field(default_factory=list)
  95. allowedRelationships: list[str] = Field(default_factory=list)
  96. allowedProperties: dict[str, list[str]] = Field(default_factory=dict)
  97. businessRules: str = ""
  98. maxDepth: int = Field(default=3, ge=1, le=10)
  99. entityMentions: list[str] = Field(default_factory=list)
  100. class AnswerRequest(BaseModel):
  101. question: str = Field(min_length=1)
  102. evidences: list[dict[str, Any]] = Field(default_factory=list)
  103. class GovernanceRequest(BaseModel):
  104. sourceType: str
  105. schemaText: str
  106. sourceDescription: str = ""
  107. maxExamples: int = Field(default=8, ge=1, le=20)
  108. class PlanRequest(BaseModel):
  109. question: str
  110. capabilities: dict[str, str] = Field(default_factory=dict)
  111. class RepairRequest(BaseModel):
  112. language: str
  113. question: str
  114. query: str
  115. error: str
  116. schemaText: str
  117. maxRows: int = 50
  118. maxDepth: int = 3
  119. def _read_only(text: str, language: str) -> str:
  120. match = re.search(r"```(?:sql|cypher)?\s*(.*?)```", text, re.I | re.S)
  121. query = (match.group(1) if match else text).strip()
  122. if query.endswith(";"):
  123. query = query[:-1].rstrip()
  124. if ";" in query:
  125. raise ValueError(f"generated {language} contains multiple statements")
  126. readonly_enabled = SETTINGS["security"]["readonly_sql" if language == "SQL" else "readonly_cypher"]
  127. if not readonly_enabled:
  128. return query
  129. forbidden = r"\b(insert|update|delete|merge|create|drop|alter|truncate|grant|revoke|load\s+csv|call)\b"
  130. if re.search(forbidden, query, re.I):
  131. raise ValueError(f"generated {language} contains a write or unsafe operation")
  132. if language == "SQL" and not re.match(r"^(select|with)\b", query, re.I):
  133. raise ValueError(f"generated SQL is not a SELECT/CTE: {query[:200]!r}")
  134. if language == "Cypher" and not re.match(r"^(match|optional\s+match|with|unwind)\b", query, re.I):
  135. raise ValueError("generated Cypher is not read-only")
  136. return query
  137. def _extract_code(text: str, language: str) -> str:
  138. text = re.sub(r"<think>.*?</think>", "", text, flags=re.I | re.S).strip()
  139. match = re.search(rf"```(?:{language.lower()})?\s*(.*?)```", text, re.I | re.S)
  140. if match:
  141. return match.group(1).strip()
  142. start = r"\b(?:SELECT|WITH)\b" if language == "SQL" else r"\b(?:MATCH|OPTIONAL\s+MATCH|WITH|UNWIND)\b"
  143. statement = re.search(start + r"[\s\S]*", text, re.I)
  144. extracted = (statement.group(0) if statement else text).strip()
  145. return re.split(r"\n\s*(?:Explanation|Reasoning|说明|解释)\s*[::]", extracted, maxsplit=1, flags=re.I)[0].strip()
  146. def _llm_configured() -> bool:
  147. return SETTINGS["llm"]["provider"].lower() == "openai" and bool(os.getenv("OPENAI_API_KEY"))
  148. def _neo4j_configured() -> bool:
  149. neo4j = SETTINGS["neo4j"]
  150. return bool(neo4j.get("uri") and neo4j.get("username") and os.getenv("NEO4J_PASSWORD"))
  151. def _openai_client(timeout: int | None = None):
  152. from openai import OpenAI
  153. return OpenAI(api_key=os.environ["OPENAI_API_KEY"], base_url=SETTINGS["llm"]["base_url"],
  154. timeout=timeout or SETTINGS["llm"]["timeout"], max_retries=0)
  155. def _json_object(text: str) -> dict:
  156. cleaned = re.sub(r"<think>.*?</think>", "", text or "", flags=re.I | re.S).strip()
  157. fenced = re.search(r"```(?:json)?\s*(.*?)```", cleaned, re.I | re.S)
  158. candidate = fenced.group(1) if fenced else cleaned
  159. start, end = candidate.find("{"), candidate.rfind("}")
  160. if start < 0 or end < start:
  161. raise ValueError("LLM did not return a JSON object")
  162. return json.loads(candidate[start:end + 1])
  163. def _short_error(error: str) -> str:
  164. return re.sub(r"(?i)(password|token|api[_ -]?key)\s*[:=]\s*\S+", r"\1=[redacted]", error)[:800]
  165. @app.get("/health")
  166. def health():
  167. return {
  168. "ok": True,
  169. "llmConfigured": _llm_configured(),
  170. "neo4jConfigured": _neo4j_configured(),
  171. "vannaEnabled": bool(SETTINGS["vanna"]["enabled"]),
  172. "text2cypherEnabled": bool(SETTINGS["text2cypher"]["enabled"]),
  173. }
  174. @app.post("/text2sql")
  175. def text2sql(req: Text2SqlRequest):
  176. if not SETTINGS["vanna"]["enabled"]:
  177. raise HTTPException(status_code=503, detail="Vanna SQL generation is disabled")
  178. if not _llm_configured():
  179. raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
  180. try:
  181. import pandas as pd
  182. from openai import OpenAI
  183. from vanna.base import VannaBase
  184. from vanna.openai import OpenAI_Chat
  185. class RequestContext(VannaBase):
  186. def __init__(self):
  187. self.ddl = [req.ddl] if req.ddl else []
  188. self.documentation = [req.documentation] if req.documentation else []
  189. self.examples = req.examples
  190. def get_related_ddl(self, question: str, **kwargs) -> list:
  191. return self.ddl
  192. def get_related_documentation(self, question: str, **kwargs) -> list:
  193. return self.documentation
  194. def get_similar_question_sql(self, question: str, **kwargs) -> list:
  195. return self.examples
  196. def generate_embedding(self, data: str, **kwargs) -> list[float]:
  197. return []
  198. def add_ddl(self, ddl: str, **kwargs) -> str:
  199. self.ddl.append(ddl)
  200. return str(len(self.ddl))
  201. def add_documentation(self, documentation: str, **kwargs) -> str:
  202. self.documentation.append(documentation)
  203. return str(len(self.documentation))
  204. def add_question_sql(self, question: str, sql: str, **kwargs) -> str:
  205. self.examples.append({"question": question, "sql": sql})
  206. return str(len(self.examples))
  207. def get_training_data(self, **kwargs) -> pd.DataFrame:
  208. return pd.DataFrame()
  209. def remove_training_data(self, id: str, **kwargs) -> bool:
  210. return False
  211. class Vanna(RequestContext, OpenAI_Chat):
  212. def __init__(self):
  213. RequestContext.__init__(self)
  214. client = OpenAI(
  215. api_key=os.environ["OPENAI_API_KEY"],
  216. base_url=SETTINGS["llm"]["base_url"],
  217. timeout=SETTINGS["llm"]["timeout"],
  218. max_retries=0,
  219. )
  220. OpenAI_Chat.__init__(self, client=client, config={
  221. "model": SETTINGS["llm"]["model"],
  222. "temperature": SETTINGS["llm"]["temperature"],
  223. })
  224. def submit_prompt(self, prompt, **kwargs) -> str:
  225. response = self.client.chat.completions.create(
  226. model=SETTINGS["llm"]["model"],
  227. messages=prompt,
  228. temperature=SETTINGS["llm"]["temperature"],
  229. max_tokens=SETTINGS["llm"]["generation_max_tokens"],
  230. )
  231. return response.choices[0].message.content or ""
  232. vn = Vanna()
  233. prompt = [{
  234. "role": "system",
  235. "content": (
  236. f"You generate exactly one final read-only {req.dialect} SQL query. "
  237. "Return SQL only, without comments, explanation or intermediate queries. "
  238. "Use only the supplied schema. Never generate DML or DDL. "
  239. f"Limit results to at most {req.maxRows} rows."
  240. ),
  241. }, {
  242. "role": "user",
  243. "content": (
  244. f"Schema:\n{req.ddl}\nDocumentation:\n{req.documentation}\n"
  245. f"Allowed tables: {req.tableWhitelist}\nEntity mentions that must be grounded to real columns: {req.entityMentions}. Unless an exact stored value is supplied by examples, use LIKE for abbreviated entity names.\nExamples: {req.examples}\nQuestion: {req.query}"
  246. ),
  247. }]
  248. sql = _read_only(_extract_code(vn.submit_prompt(prompt), "SQL"), "SQL")
  249. return {"sql": sql, "confidence": 0.0, "usedContext": ["ddl"] if req.ddl else [], "warnings": []}
  250. except HTTPException:
  251. raise
  252. except Exception as exc:
  253. raise HTTPException(status_code=503, detail=f"Vanna SQL generation unavailable: {exc}") from exc
  254. @app.post("/text2cypher")
  255. def text2cypher(req: Text2CypherRequest):
  256. if not SETTINGS["text2cypher"]["enabled"]:
  257. raise HTTPException(status_code=503, detail="Text-to-Cypher generation is disabled")
  258. if not _llm_configured():
  259. raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
  260. try:
  261. from openai import OpenAI
  262. llm = OpenAI(
  263. api_key=os.environ["OPENAI_API_KEY"],
  264. base_url=SETTINGS["llm"]["base_url"],
  265. timeout=SETTINGS["llm"]["timeout"],
  266. max_retries=0,
  267. )
  268. prompt = (
  269. "Generate one read-only Cypher query for the question. "
  270. "Return only Cypher, without explanation. Never use CREATE, MERGE, DELETE, SET, REMOVE, DROP, LOAD CSV or CALL.\n"
  271. "Return nodes, relationships, or paths that carry the requested properties; do not return only scalar projections.\n"
  272. "Every node pattern MUST declare exactly one label copied EXACTLY from allowedLabels (preserve uppercase, underscore, spelling). "
  273. "NEVER invent, translate, pluralize, or guess labels from the question text — every label in the Cypher MUST appear verbatim in allowedLabels. "
  274. "If a concept has no exact match, pick the closest allowedLabels entry and reuse its exact spelling. "
  275. "Repeating a previously-bound variable is fine, but any new node variable must carry a label.\n"
  276. f"Schema:\n{req.schema}\n"
  277. f"Allowed labels: {req.allowedLabels}\n"
  278. f"Allowed relationships: {req.allowedRelationships}\n"
  279. f"Allowed properties: {req.allowedProperties}\n"
  280. f"Business rules:\n{req.businessRules}\n"
  281. f"Maximum path depth: {req.maxDepth}\n"
  282. f"Entity mentions that must be grounded to an allowed property (prefer name/title/code): {req.entityMentions}. User names may be abbreviations; unless an exact stored value is known from examples, use CONTAINS instead of equality.\n"
  283. f"Examples:\n{req.examples}\n"
  284. f"Question: {req.query}"
  285. )
  286. response = llm.chat.completions.create(
  287. model=SETTINGS["llm"]["model"],
  288. messages=[{"role": "user", "content": prompt}],
  289. temperature=SETTINGS["llm"]["temperature"],
  290. max_tokens=SETTINGS["llm"]["generation_max_tokens"],
  291. )
  292. cypher = _read_only(_extract_code(response.choices[0].message.content or "", "Cypher"), "Cypher")
  293. return {"cypher": cypher, "confidence": 0.0, "usedSchema": req.allowedLabels, "warnings": []}
  294. except HTTPException:
  295. raise
  296. except Exception as exc:
  297. raise HTTPException(status_code=503, detail=f"Neo4j GraphRAG Cypher generation unavailable: {exc}") from exc
  298. @app.post("/answer")
  299. def answer(req: AnswerRequest):
  300. if not _llm_configured():
  301. raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
  302. try:
  303. from openai import OpenAI
  304. client = OpenAI(
  305. api_key=os.environ["OPENAI_API_KEY"],
  306. base_url=SETTINGS["llm"]["base_url"],
  307. timeout=SETTINGS["llm"]["timeout"],
  308. max_retries=0,
  309. )
  310. evidence_json = json.dumps(req.evidences[:20], ensure_ascii=False, default=str)
  311. response = client.chat.completions.create(
  312. model=SETTINGS["llm"]["model"],
  313. messages=[
  314. {"role": "system", "content": "你是多源RAG回答助手。只能依据给定证据作答,明确区分文档、结构化数据和图谱依据;证据不足时直说,不得编造。回答使用简体中文。"},
  315. {"role": "user", "content": f"问题:{req.question}\n\n证据:{evidence_json}"},
  316. ],
  317. temperature=SETTINGS["llm"]["temperature"],
  318. max_tokens=SETTINGS["llm"]["answer_max_tokens"],
  319. )
  320. return {"answer": response.choices[0].message.content or "", "warnings": []}
  321. except Exception as exc:
  322. raise HTTPException(status_code=503, detail=f"Answer generation unavailable: {exc}") from exc
  323. @app.post("/governance/suggest")
  324. def governance_suggest(req: GovernanceRequest):
  325. if not _llm_configured(): raise HTTPException(status_code=503, detail="LLM is not configured")
  326. prompt = f"""Analyze the real {req.sourceType} schema and propose a safe business-facing RAG policy.
  327. Return JSON only with keys: summary, allowedLabels, allowedRelationships, allowedProperties,
  328. tableWhitelist, documentation, businessRules, examples. examples must contain question plus sql or cypher.
  329. Never invent schema names. Prefer a coherent business subgraph over opening unrelated technical metadata.
  330. Generate at most {req.maxExamples} representative reviewed-candidate examples covering lookup, filtering,
  331. aggregation or graph traversal as applicable.
  332. Source description: {req.sourceDescription}
  333. Schema:\n{req.schemaText}"""
  334. try:
  335. response = _openai_client(12).chat.completions.create(model=SETTINGS["llm"]["model"],
  336. messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"],
  337. max_tokens=SETTINGS["llm"]["answer_max_tokens"])
  338. return _json_object(response.choices[0].message.content or "")
  339. except Exception as exc:
  340. raise HTTPException(status_code=503, detail=f"governance suggestion unavailable: {exc}") from exc
  341. @app.post("/plan")
  342. def plan(req: PlanRequest):
  343. if not _llm_configured(): raise HTTPException(status_code=503, detail="LLM is not configured")
  344. prompt = f"""Split the user question into source-specific retrieval questions. Return JSON only:
  345. {{"DOCUMENT":"...","STRUCTURED_DATA":"...","GRAPH":"..."}}.
  346. Omit sources that cannot contribute according to their capability description. Preserve the user's intent.
  347. Question: {req.question}\nCapabilities: {json.dumps(req.capabilities, ensure_ascii=False)}"""
  348. try:
  349. response = _openai_client(12).chat.completions.create(model=SETTINGS["llm"]["model"],
  350. messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"], max_tokens=1024)
  351. return _json_object(response.choices[0].message.content or "")
  352. except Exception as exc:
  353. raise HTTPException(status_code=503, detail=f"question planning unavailable: {exc}") from exc
  354. @app.post("/repair")
  355. def repair(req: RepairRequest):
  356. language = req.language.upper()
  357. prompt = f"""Repair this read-only {language} query after EXPLAIN failed. Return only the corrected query.
  358. Use only the supplied schema. Make exactly one statement. Do not use write operations.
  359. The failed query used a label or relationship type that is NOT in the Schema below — open the Schema, find the closest matching entry, and copy its name EXACTLY (preserve uppercase, underscore, spelling).
  360. NEVER invent, translate, pluralize, or guess labels from the question text; every label/relationship MUST appear verbatim in the Schema.
  361. Every node pattern MUST declare exactly one label from the Schema — write (p:Person), never (p) or ().
  362. Question: {req.question}\nSchema: {req.schemaText}\nFailed query: {req.query}\nError: {_short_error(req.error)}
  363. Maximum rows: {req.maxRows}; maximum graph depth: {req.maxDepth}."""
  364. try:
  365. response = _openai_client(SETTINGS["llm"]["timeout"]).chat.completions.create(model=SETTINGS["llm"]["model"],
  366. messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"],
  367. max_tokens=SETTINGS["llm"]["generation_max_tokens"])
  368. fixed = _read_only(_extract_code(response.choices[0].message.content or "", language), language)
  369. return {"query": fixed}
  370. except Exception as exc:
  371. raise HTTPException(status_code=503, detail=f"query repair unavailable: {exc}") from exc
  372. if __name__ == "__main__":
  373. import uvicorn
  374. uvicorn.run(app, host=str(SETTINGS["server"]["host"]), port=int(SETTINGS["server"]["port"]))