server.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  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": 8192,
  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. schemaVersion: str = ""
  85. documentation: str = ""
  86. examples: list[Any] = Field(default_factory=list)
  87. tableWhitelist: list[str] = Field(default_factory=list)
  88. selectedTables: list[str] = Field(default_factory=list)
  89. foreignKeys: list[dict[str, Any]] = Field(default_factory=list)
  90. relationships: list[dict[str, Any]] = Field(default_factory=list)
  91. sampledValues: dict[str, list[Any]] = Field(default_factory=dict)
  92. referenceRows: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
  93. contextWarnings: list[str] = Field(default_factory=list)
  94. maxRows: int = Field(default=SETTINGS["security"]["default_limit"], ge=1, le=1000)
  95. entityMentions: list[str] = Field(default_factory=list)
  96. class Text2CypherRequest(BaseModel):
  97. query: str = Field(min_length=1)
  98. graphSourceId: int
  99. schema: str = ""
  100. examples: list[Any] = Field(default_factory=list)
  101. allowedLabels: list[str] = Field(default_factory=list)
  102. allowedRelationships: list[str] = Field(default_factory=list)
  103. allowedProperties: dict[str, list[str]] = Field(default_factory=dict)
  104. businessRules: str = ""
  105. maxDepth: int = Field(default=3, ge=1, le=10)
  106. entityMentions: list[str] = Field(default_factory=list)
  107. class AnswerRequest(BaseModel):
  108. question: str = Field(min_length=1)
  109. evidences: list[dict[str, Any]] = Field(default_factory=list)
  110. class GovernanceRequest(BaseModel):
  111. sourceType: str
  112. schemaText: str
  113. sourceDescription: str = ""
  114. maxExamples: int = Field(default=8, ge=1, le=20)
  115. class PlanRequest(BaseModel):
  116. question: str
  117. capabilities: dict[str, str] = Field(default_factory=dict)
  118. class RepairRequest(BaseModel):
  119. language: str
  120. question: str
  121. query: str
  122. error: str
  123. schemaText: str
  124. dialect: str = ""
  125. selectedTables: list[str] = Field(default_factory=list)
  126. foreignKeys: list[dict[str, Any]] = Field(default_factory=list)
  127. relationships: list[dict[str, Any]] = Field(default_factory=list)
  128. sampledValues: dict[str, list[Any]] = Field(default_factory=dict)
  129. referenceRows: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
  130. maxRows: int = 50
  131. maxDepth: int = 3
  132. def _read_only(text: str, language: str) -> str:
  133. match = re.search(r"```(?:sql|cypher)?\s*(.*?)```", text, re.I | re.S)
  134. query = (match.group(1) if match else text).strip()
  135. if query.endswith(";"):
  136. query = query[:-1].rstrip()
  137. if ";" in query:
  138. raise ValueError(f"generated {language} contains multiple statements")
  139. readonly_enabled = SETTINGS["security"]["readonly_sql" if language == "SQL" else "readonly_cypher"]
  140. if not readonly_enabled:
  141. return query
  142. forbidden = r"\b(insert|update|delete|merge|create|drop|alter|truncate|grant|revoke|load\s+csv|call)\b"
  143. if re.search(forbidden, query, re.I):
  144. raise ValueError(f"generated {language} contains a write or unsafe operation")
  145. if language == "SQL" and not re.match(r"^(select|with)\b", query, re.I):
  146. raise ValueError(f"generated SQL is not a SELECT/CTE: {query[:200]!r}")
  147. if language == "Cypher" and not re.match(r"^(match|optional\s+match|with|unwind)\b", query, re.I):
  148. raise ValueError("generated Cypher is not read-only")
  149. return query
  150. def _extract_code(text: str, language: str) -> str:
  151. text = re.sub(r"<think>.*?</think>", "", text, flags=re.I | re.S).strip()
  152. match = re.search(rf"```(?:{language.lower()})?\s*(.*?)```", text, re.I | re.S)
  153. if match:
  154. return match.group(1).strip()
  155. start = r"\b(?:SELECT|WITH)\b" if language == "SQL" else r"\b(?:MATCH|OPTIONAL\s+MATCH|WITH|UNWIND)\b"
  156. statement = re.search(start + r"[\s\S]*", text, re.I)
  157. extracted = (statement.group(0) if statement else text).strip()
  158. return re.split(r"\n\s*(?:Explanation|Reasoning|说明|解释)\s*[::]", extracted, maxsplit=1, flags=re.I)[0].strip()
  159. def _llm_configured() -> bool:
  160. return SETTINGS["llm"]["provider"].lower() == "openai" and bool(os.getenv("OPENAI_API_KEY"))
  161. def _neo4j_configured() -> bool:
  162. neo4j = SETTINGS["neo4j"]
  163. return bool(neo4j.get("uri") and neo4j.get("username") and os.getenv("NEO4J_PASSWORD"))
  164. def _openai_client(timeout: int | None = None):
  165. from openai import OpenAI
  166. return OpenAI(api_key=os.environ["OPENAI_API_KEY"], base_url=SETTINGS["llm"]["base_url"],
  167. timeout=_client_timeout(timeout), max_retries=0)
  168. def _client_timeout(timeout: int | None = None):
  169. configured = SETTINGS["llm"]["timeout"] if timeout is None else timeout
  170. return None if configured is None or configured <= 0 else configured
  171. def _completion_content(response) -> str:
  172. choice = response.choices[0]
  173. content = choice.message.content or ""
  174. if content.strip():
  175. return content
  176. finish_reason = getattr(choice, "finish_reason", None) or "unknown"
  177. raise ValueError(f"model returned empty content (finish_reason={finish_reason})")
  178. def _json_object(text: str) -> dict:
  179. cleaned = re.sub(r"<think>.*?</think>", "", text or "", flags=re.I | re.S).strip()
  180. fenced = re.search(r"```(?:json)?\s*(.*?)```", cleaned, re.I | re.S)
  181. candidate = fenced.group(1) if fenced else cleaned
  182. start, end = candidate.find("{"), candidate.rfind("}")
  183. if start < 0 or end < start:
  184. raise ValueError("LLM did not return a JSON object")
  185. return json.loads(candidate[start:end + 1])
  186. def _short_error(error: str) -> str:
  187. return re.sub(r"(?i)(password|token|api[_ -]?key)\s*[:=]\s*\S+", r"\1=[redacted]", error)[:800]
  188. def _text2sql_messages(req: Text2SqlRequest) -> list[dict[str, str]]:
  189. if req.dialect.lower() != "mysql":
  190. raise ValueError("Text2SQL generation currently supports MySQL only")
  191. context = {
  192. "dialect": "mysql",
  193. "schemaVersion": req.schemaVersion,
  194. "selectedTables": req.selectedTables,
  195. "minimalDdl": req.ddl,
  196. "foreignKeys": req.foreignKeys,
  197. "relationships": req.relationships,
  198. "sampledValues": req.sampledValues,
  199. "referenceRows": req.referenceRows,
  200. "entityMentions": req.entityMentions,
  201. "generationRules": req.documentation,
  202. }
  203. return [{
  204. "role": "system",
  205. "content": (
  206. "You generate exactly one final read-only MySQL SQL query. "
  207. "Return SQL only, without comments, explanation or intermediate queries. "
  208. "Use only tables and columns in minimalDdl and JOIN only through explicitly provided relationships. "
  209. "A CONDITIONAL relationship requires every listed condition in the JOIN or WHERE clause. "
  210. "Filter literals must be grounded in sampledValues/referenceRows. "
  211. "Never invent business mappings, identifiers, columns, tables, values, joins or answers. "
  212. "Preserve conjunctions: a request for both A and B must require both, normally with GROUP BY/HAVING or equivalent logic. "
  213. "For highest/best by a stated score, ORDER BY that score DESC; never replace it with nearest distance. "
  214. "Use MySQL syntax only; DISTINCT ON is forbidden. Never generate DML or DDL. "
  215. f"Limit results to at most {req.maxRows} rows."
  216. ),
  217. }, {
  218. "role": "user",
  219. "content": (
  220. "Verified query context (JSON):\n"
  221. + json.dumps(context, ensure_ascii=False, default=str)
  222. + f"\nQuestion: {req.query}"
  223. ),
  224. }]
  225. @app.get("/health")
  226. def health():
  227. return {
  228. "ok": True,
  229. "llmConfigured": _llm_configured(),
  230. "neo4jConfigured": _neo4j_configured(),
  231. "vannaEnabled": bool(SETTINGS["vanna"]["enabled"]),
  232. "text2cypherEnabled": bool(SETTINGS["text2cypher"]["enabled"]),
  233. }
  234. @app.post("/text2sql")
  235. def text2sql(req: Text2SqlRequest):
  236. if not SETTINGS["vanna"]["enabled"]:
  237. raise HTTPException(status_code=503, detail="Vanna SQL generation is disabled")
  238. if not _llm_configured():
  239. raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
  240. try:
  241. response = _openai_client().chat.completions.create(
  242. model=SETTINGS["llm"]["model"], messages=_text2sql_messages(req),
  243. temperature=SETTINGS["llm"]["temperature"],
  244. max_tokens=SETTINGS["llm"]["generation_max_tokens"],
  245. )
  246. sql = _read_only(_extract_code(_completion_content(response), "SQL"), "SQL")
  247. return {"sql": sql, "confidence": 0.0,
  248. "usedContext": ["minimalDdl", "relationships", "sampledValues", "referenceRows"],
  249. "warnings": req.contextWarnings}
  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=_client_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. candidate = _extract_code(response.choices[0].message.content or "", "Cypher")
  293. warnings = []
  294. try:
  295. cypher = _read_only(candidate, "Cypher")
  296. except ValueError as exc:
  297. # The bridge only generates candidates. Java performs the authoritative
  298. # schema validation, read-only Guard and the single allowed repair before execution.
  299. cypher = candidate
  300. warnings.append(str(exc))
  301. return {"cypher": cypher, "confidence": 0.0, "usedSchema": req.allowedLabels, "warnings": warnings}
  302. except HTTPException:
  303. raise
  304. except Exception as exc:
  305. raise HTTPException(status_code=503, detail=f"Neo4j GraphRAG Cypher generation unavailable: {exc}") from exc
  306. @app.post("/answer")
  307. def answer(req: AnswerRequest):
  308. if not _llm_configured():
  309. raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
  310. try:
  311. from openai import OpenAI
  312. client = OpenAI(
  313. api_key=os.environ["OPENAI_API_KEY"],
  314. base_url=SETTINGS["llm"]["base_url"],
  315. timeout=_client_timeout(),
  316. max_retries=0,
  317. )
  318. evidence_json = json.dumps(req.evidences[:20], ensure_ascii=False, default=str)
  319. response = client.chat.completions.create(
  320. model=SETTINGS["llm"]["model"],
  321. messages=[
  322. {"role": "system", "content": "你是多源RAG回答助手。只能依据给定证据作答,明确区分文档、结构化数据和图谱依据;证据不足时直说,不得编造。回答使用简体中文。"},
  323. {"role": "user", "content": f"问题:{req.question}\n\n证据:{evidence_json}"},
  324. ],
  325. temperature=SETTINGS["llm"]["temperature"],
  326. max_tokens=SETTINGS["llm"]["answer_max_tokens"],
  327. )
  328. return {"answer": response.choices[0].message.content or "", "warnings": []}
  329. except Exception as exc:
  330. raise HTTPException(status_code=503, detail=f"Answer generation unavailable: {exc}") from exc
  331. @app.post("/governance/suggest")
  332. def governance_suggest(req: GovernanceRequest):
  333. if not _llm_configured(): raise HTTPException(status_code=503, detail="LLM is not configured")
  334. prompt = f"""Analyze the real {req.sourceType} schema and propose a safe business-facing RAG policy.
  335. Return JSON only with keys: summary, allowedLabels, allowedRelationships, allowedProperties,
  336. tableWhitelist, documentation, businessRules, examples. examples must contain question plus sql or cypher.
  337. Never invent schema names. Prefer a coherent business subgraph over opening unrelated technical metadata.
  338. Generate at most {req.maxExamples} representative reviewed-candidate examples covering lookup, filtering,
  339. aggregation or graph traversal as applicable.
  340. Source description: {req.sourceDescription}
  341. Schema:\n{req.schemaText}"""
  342. try:
  343. response = _openai_client(12).chat.completions.create(model=SETTINGS["llm"]["model"],
  344. messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"],
  345. max_tokens=SETTINGS["llm"]["answer_max_tokens"])
  346. return _json_object(response.choices[0].message.content or "")
  347. except Exception as exc:
  348. raise HTTPException(status_code=503, detail=f"governance suggestion unavailable: {exc}") from exc
  349. @app.post("/plan")
  350. def plan(req: PlanRequest):
  351. if not _llm_configured(): raise HTTPException(status_code=503, detail="LLM is not configured")
  352. prompt = f"""Split the user question into source-specific retrieval questions. Return JSON only:
  353. {{"DOCUMENT":"...","STRUCTURED_DATA":"...","GRAPH":"..."}}.
  354. Omit sources that cannot contribute according to their capability description. Preserve the user's intent.
  355. Question: {req.question}\nCapabilities: {json.dumps(req.capabilities, ensure_ascii=False)}"""
  356. try:
  357. response = _openai_client(12).chat.completions.create(model=SETTINGS["llm"]["model"],
  358. messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"], max_tokens=1024)
  359. return _json_object(response.choices[0].message.content or "")
  360. except Exception as exc:
  361. raise HTTPException(status_code=503, detail=f"question planning unavailable: {exc}") from exc
  362. @app.post("/repair")
  363. def repair(req: RepairRequest):
  364. language = req.language.upper()
  365. if language == "SQL":
  366. context = json.dumps({
  367. "dialect": req.dialect or "mysql",
  368. "selectedTables": req.selectedTables,
  369. "minimalDdl": req.schemaText,
  370. "foreignKeys": req.foreignKeys,
  371. "relationships": req.relationships,
  372. "sampledValues": req.sampledValues,
  373. "referenceRows": req.referenceRows,
  374. }, ensure_ascii=False, default=str)
  375. prompt = f"""Repair this read-only MySQL query after EXPLAIN failed. Return only one corrected SQL statement.
  376. Use only tables and columns in minimalDdl, only explicitly provided relationships, and only sampled/reference values.
  377. Every CONDITIONAL relationship must include all listed conditions. Avoid cartesian products.
  378. Never invent business mappings, schema identifiers, values or answers. Preserve AND semantics for simultaneous conditions.
  379. For highest/best by a stated score use ORDER BY that score DESC. Do not use PostgreSQL DISTINCT ON.
  380. Question: {req.question}
  381. Verified context: {context}
  382. Failed query: {req.query}
  383. Error: {_short_error(req.error)}
  384. Maximum rows: {req.maxRows}."""
  385. else:
  386. prompt = f"""Repair this read-only {language} query after EXPLAIN failed. Return only the corrected query.
  387. Use only the supplied schema. Make exactly one statement. Do not use write operations.
  388. 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).
  389. NEVER invent, translate, pluralize, or guess labels from the question text; every label/relationship MUST appear verbatim in the Schema.
  390. Every node pattern MUST declare exactly one label from the Schema — write (p:Person), never (p) or ().
  391. Question: {req.question}\nSchema: {req.schemaText}\nFailed query: {req.query}\nError: {_short_error(req.error)}
  392. Maximum rows: {req.maxRows}; maximum graph depth: {req.maxDepth}."""
  393. try:
  394. response = _openai_client(SETTINGS["llm"]["timeout"]).chat.completions.create(model=SETTINGS["llm"]["model"],
  395. messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"],
  396. max_tokens=SETTINGS["llm"]["generation_max_tokens"])
  397. fixed = _read_only(_extract_code(response.choices[0].message.content or "", language), language)
  398. return {"query": fixed}
  399. except Exception as exc:
  400. raise HTTPException(status_code=503, detail=f"query repair unavailable: {exc}") from exc
  401. if __name__ == "__main__":
  402. import uvicorn
  403. uvicorn.run(app, host=str(SETTINGS["server"]["host"]), port=int(SETTINGS["server"]["port"]))