| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467 |
- import logging
- import json
- import os
- import re
- from pathlib import Path
- from typing import Any
- import yaml
- from dotenv import load_dotenv
- from fastapi import FastAPI, HTTPException
- from pydantic import BaseModel, Field
- BRIDGE_DIR = Path(__file__).resolve().parent
- CONFIG_PATH = BRIDGE_DIR / "config.yaml"
- ENV_PATH = BRIDGE_DIR / ".env"
- DEFAULTS = {
- "server": {"host": "127.0.0.1", "port": 18733},
- "llm": {
- "provider": "openai",
- "model": "gpt-4o-mini",
- "base_url": "https://api.openai.com/v1",
- "temperature": 1.0,
- "timeout": 60,
- "generation_max_tokens": 8192,
- "answer_max_tokens": 4096,
- },
- "vanna": {"enabled": True},
- "text2cypher": {"enabled": True},
- "neo4j": {"uri": "bolt://127.0.0.1:7687", "username": "neo4j"},
- "security": {"readonly_sql": True, "readonly_cypher": True, "default_limit": 50},
- }
- ENV_MAPPINGS = {
- "RAG_AI_BRIDGE_HOST": ("server", "host", str),
- "RAG_AI_BRIDGE_PORT": ("server", "port", int),
- "LLM_PROVIDER": ("llm", "provider", str),
- "OPENAI_MODEL": ("llm", "model", str),
- "OPENAI_BASE_URL": ("llm", "base_url", str),
- "LLM_TEMPERATURE": ("llm", "temperature", float),
- "LLM_TIMEOUT": ("llm", "timeout", int),
- "LLM_GENERATION_MAX_TOKENS": ("llm", "generation_max_tokens", int),
- "LLM_ANSWER_MAX_TOKENS": ("llm", "answer_max_tokens", int),
- "VANNA_ENABLED": ("vanna", "enabled", "bool"),
- "TEXT2CYPHER_ENABLED": ("text2cypher", "enabled", "bool"),
- "NEO4J_URI": ("neo4j", "uri", str),
- "NEO4J_USERNAME": ("neo4j", "username", str),
- "READONLY_SQL": ("security", "readonly_sql", "bool"),
- "READONLY_CYPHER": ("security", "readonly_cypher", "bool"),
- "DEFAULT_LIMIT": ("security", "default_limit", int),
- }
- def _merge(base: dict, override: dict) -> dict:
- result = {key: value.copy() if isinstance(value, dict) else value for key, value in base.items()}
- for key, value in override.items():
- if isinstance(value, dict) and isinstance(result.get(key), dict):
- result[key] = _merge(result[key], value)
- else:
- result[key] = value
- return result
- def _convert(value: str, converter):
- if converter == "bool":
- return value.strip().lower() in {"1", "true", "yes", "on"}
- return converter(value)
- def load_settings() -> dict:
- file_config = {}
- if CONFIG_PATH.exists():
- with CONFIG_PATH.open("r", encoding="utf-8") as stream:
- file_config = yaml.safe_load(stream) or {}
- if not isinstance(file_config, dict):
- raise ValueError("config.yaml root must be a mapping")
- else:
- logging.warning("config.yaml not found; using defaults. Copy config.example.yaml to config.yaml to customize.")
- # override=False 保证操作系统环境变量优先于 .env。
- load_dotenv(ENV_PATH, override=False)
- settings = _merge(DEFAULTS, file_config)
- for env_name, (section, key, converter) in ENV_MAPPINGS.items():
- value = os.getenv(env_name)
- if value is not None and value != "":
- settings.setdefault(section, {})[key] = _convert(value, converter)
- return settings
- SETTINGS = load_settings()
- app = FastAPI(title="RAG AI Bridge", version="1.1.0")
- class Text2SqlRequest(BaseModel):
- query: str = Field(min_length=1)
- datasourceId: int
- dialect: str = "mysql"
- ddl: str = ""
- schemaVersion: str = ""
- documentation: str = ""
- examples: list[Any] = Field(default_factory=list)
- tableWhitelist: list[str] = Field(default_factory=list)
- selectedTables: list[str] = Field(default_factory=list)
- foreignKeys: list[dict[str, Any]] = Field(default_factory=list)
- relationships: list[dict[str, Any]] = Field(default_factory=list)
- sampledValues: dict[str, list[Any]] = Field(default_factory=dict)
- referenceRows: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
- contextWarnings: list[str] = Field(default_factory=list)
- maxRows: int = Field(default=SETTINGS["security"]["default_limit"], ge=1, le=1000)
- entityMentions: list[str] = Field(default_factory=list)
- class Text2CypherRequest(BaseModel):
- query: str = Field(min_length=1)
- graphSourceId: int
- schema: str = ""
- examples: list[Any] = Field(default_factory=list)
- allowedLabels: list[str] = Field(default_factory=list)
- allowedRelationships: list[str] = Field(default_factory=list)
- allowedProperties: dict[str, list[str]] = Field(default_factory=dict)
- businessRules: str = ""
- maxDepth: int = Field(default=3, ge=1, le=10)
- entityMentions: list[str] = Field(default_factory=list)
- class AnswerRequest(BaseModel):
- question: str = Field(min_length=1)
- evidences: list[dict[str, Any]] = Field(default_factory=list)
- class GovernanceRequest(BaseModel):
- sourceType: str
- schemaText: str
- sourceDescription: str = ""
- maxExamples: int = Field(default=8, ge=1, le=20)
- class PlanRequest(BaseModel):
- question: str
- capabilities: dict[str, str] = Field(default_factory=dict)
- class RepairRequest(BaseModel):
- language: str
- question: str
- query: str
- error: str
- schemaText: str
- dialect: str = ""
- selectedTables: list[str] = Field(default_factory=list)
- foreignKeys: list[dict[str, Any]] = Field(default_factory=list)
- relationships: list[dict[str, Any]] = Field(default_factory=list)
- sampledValues: dict[str, list[Any]] = Field(default_factory=dict)
- referenceRows: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
- maxRows: int = 50
- maxDepth: int = 3
- def _read_only(text: str, language: str) -> str:
- match = re.search(r"```(?:sql|cypher)?\s*(.*?)```", text, re.I | re.S)
- query = (match.group(1) if match else text).strip()
- if query.endswith(";"):
- query = query[:-1].rstrip()
- if ";" in query:
- raise ValueError(f"generated {language} contains multiple statements")
- readonly_enabled = SETTINGS["security"]["readonly_sql" if language == "SQL" else "readonly_cypher"]
- if not readonly_enabled:
- return query
- forbidden = r"\b(insert|update|delete|merge|create|drop|alter|truncate|grant|revoke|load\s+csv|call)\b"
- if re.search(forbidden, query, re.I):
- raise ValueError(f"generated {language} contains a write or unsafe operation")
- if language == "SQL" and not re.match(r"^(select|with)\b", query, re.I):
- raise ValueError(f"generated SQL is not a SELECT/CTE: {query[:200]!r}")
- if language == "Cypher" and not re.match(r"^(match|optional\s+match|with|unwind)\b", query, re.I):
- raise ValueError("generated Cypher is not read-only")
- return query
- def _extract_code(text: str, language: str) -> str:
- text = re.sub(r"<think>.*?</think>", "", text, flags=re.I | re.S).strip()
- match = re.search(rf"```(?:{language.lower()})?\s*(.*?)```", text, re.I | re.S)
- if match:
- return match.group(1).strip()
- start = r"\b(?:SELECT|WITH)\b" if language == "SQL" else r"\b(?:MATCH|OPTIONAL\s+MATCH|WITH|UNWIND)\b"
- statement = re.search(start + r"[\s\S]*", text, re.I)
- extracted = (statement.group(0) if statement else text).strip()
- return re.split(r"\n\s*(?:Explanation|Reasoning|说明|解释)\s*[::]", extracted, maxsplit=1, flags=re.I)[0].strip()
- def _llm_configured() -> bool:
- return SETTINGS["llm"]["provider"].lower() == "openai" and bool(os.getenv("OPENAI_API_KEY"))
- def _neo4j_configured() -> bool:
- neo4j = SETTINGS["neo4j"]
- return bool(neo4j.get("uri") and neo4j.get("username") and os.getenv("NEO4J_PASSWORD"))
- def _openai_client(timeout: int | None = None):
- from openai import OpenAI
- return OpenAI(api_key=os.environ["OPENAI_API_KEY"], base_url=SETTINGS["llm"]["base_url"],
- timeout=_client_timeout(timeout), max_retries=0)
- def _client_timeout(timeout: int | None = None):
- configured = SETTINGS["llm"]["timeout"] if timeout is None else timeout
- return None if configured is None or configured <= 0 else configured
- def _completion_content(response) -> str:
- choice = response.choices[0]
- content = choice.message.content or ""
- if content.strip():
- return content
- finish_reason = getattr(choice, "finish_reason", None) or "unknown"
- raise ValueError(f"model returned empty content (finish_reason={finish_reason})")
- def _json_object(text: str) -> dict:
- cleaned = re.sub(r"<think>.*?</think>", "", text or "", flags=re.I | re.S).strip()
- fenced = re.search(r"```(?:json)?\s*(.*?)```", cleaned, re.I | re.S)
- candidate = fenced.group(1) if fenced else cleaned
- start, end = candidate.find("{"), candidate.rfind("}")
- if start < 0 or end < start:
- raise ValueError("LLM did not return a JSON object")
- return json.loads(candidate[start:end + 1])
- def _short_error(error: str) -> str:
- return re.sub(r"(?i)(password|token|api[_ -]?key)\s*[:=]\s*\S+", r"\1=[redacted]", error)[:800]
- def _text2sql_messages(req: Text2SqlRequest) -> list[dict[str, str]]:
- if req.dialect.lower() != "mysql":
- raise ValueError("Text2SQL generation currently supports MySQL only")
- context = {
- "dialect": "mysql",
- "schemaVersion": req.schemaVersion,
- "selectedTables": req.selectedTables,
- "minimalDdl": req.ddl,
- "foreignKeys": req.foreignKeys,
- "relationships": req.relationships,
- "sampledValues": req.sampledValues,
- "referenceRows": req.referenceRows,
- "entityMentions": req.entityMentions,
- "generationRules": req.documentation,
- }
- return [{
- "role": "system",
- "content": (
- "You generate exactly one final read-only MySQL SQL query. "
- "Return SQL only, without comments, explanation or intermediate queries. "
- "Use only tables and columns in minimalDdl and JOIN only through explicitly provided relationships. "
- "A CONDITIONAL relationship requires every listed condition in the JOIN or WHERE clause. "
- "Filter literals must be grounded in sampledValues/referenceRows. "
- "Never invent business mappings, identifiers, columns, tables, values, joins or answers. "
- "Preserve conjunctions: a request for both A and B must require both, normally with GROUP BY/HAVING or equivalent logic. "
- "For highest/best by a stated score, ORDER BY that score DESC; never replace it with nearest distance. "
- "Use MySQL syntax only; DISTINCT ON is forbidden. Never generate DML or DDL. "
- f"Limit results to at most {req.maxRows} rows."
- ),
- }, {
- "role": "user",
- "content": (
- "Verified query context (JSON):\n"
- + json.dumps(context, ensure_ascii=False, default=str)
- + f"\nQuestion: {req.query}"
- ),
- }]
- @app.get("/health")
- def health():
- return {
- "ok": True,
- "llmConfigured": _llm_configured(),
- "neo4jConfigured": _neo4j_configured(),
- "vannaEnabled": bool(SETTINGS["vanna"]["enabled"]),
- "text2cypherEnabled": bool(SETTINGS["text2cypher"]["enabled"]),
- }
- @app.post("/text2sql")
- def text2sql(req: Text2SqlRequest):
- if not SETTINGS["vanna"]["enabled"]:
- raise HTTPException(status_code=503, detail="Vanna SQL generation is disabled")
- if not _llm_configured():
- raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
- try:
- response = _openai_client().chat.completions.create(
- model=SETTINGS["llm"]["model"], messages=_text2sql_messages(req),
- temperature=SETTINGS["llm"]["temperature"],
- max_tokens=SETTINGS["llm"]["generation_max_tokens"],
- )
- sql = _read_only(_extract_code(_completion_content(response), "SQL"), "SQL")
- return {"sql": sql, "confidence": 0.0,
- "usedContext": ["minimalDdl", "relationships", "sampledValues", "referenceRows"],
- "warnings": req.contextWarnings}
- except HTTPException:
- raise
- except Exception as exc:
- raise HTTPException(status_code=503, detail=f"Vanna SQL generation unavailable: {exc}") from exc
- @app.post("/text2cypher")
- def text2cypher(req: Text2CypherRequest):
- if not SETTINGS["text2cypher"]["enabled"]:
- raise HTTPException(status_code=503, detail="Text-to-Cypher generation is disabled")
- if not _llm_configured():
- raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
- try:
- from openai import OpenAI
- llm = OpenAI(
- api_key=os.environ["OPENAI_API_KEY"],
- base_url=SETTINGS["llm"]["base_url"],
- timeout=_client_timeout(),
- max_retries=0,
- )
- prompt = (
- "Generate one read-only Cypher query for the question. "
- "Return only Cypher, without explanation. Never use CREATE, MERGE, DELETE, SET, REMOVE, DROP, LOAD CSV or CALL.\n"
- "Return nodes, relationships, or paths that carry the requested properties; do not return only scalar projections.\n"
- "Every node pattern MUST declare exactly one label copied EXACTLY from allowedLabels (preserve uppercase, underscore, spelling). "
- "NEVER invent, translate, pluralize, or guess labels from the question text — every label in the Cypher MUST appear verbatim in allowedLabels. "
- "If a concept has no exact match, pick the closest allowedLabels entry and reuse its exact spelling. "
- "Repeating a previously-bound variable is fine, but any new node variable must carry a label.\n"
- f"Schema:\n{req.schema}\n"
- f"Allowed labels: {req.allowedLabels}\n"
- f"Allowed relationships: {req.allowedRelationships}\n"
- f"Allowed properties: {req.allowedProperties}\n"
- f"Business rules:\n{req.businessRules}\n"
- f"Maximum path depth: {req.maxDepth}\n"
- 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"
- f"Examples:\n{req.examples}\n"
- f"Question: {req.query}"
- )
- response = llm.chat.completions.create(
- model=SETTINGS["llm"]["model"],
- messages=[{"role": "user", "content": prompt}],
- temperature=SETTINGS["llm"]["temperature"],
- max_tokens=SETTINGS["llm"]["generation_max_tokens"],
- )
- candidate = _extract_code(response.choices[0].message.content or "", "Cypher")
- warnings = []
- try:
- cypher = _read_only(candidate, "Cypher")
- except ValueError as exc:
- # The bridge only generates candidates. Java performs the authoritative
- # schema validation, read-only Guard and the single allowed repair before execution.
- cypher = candidate
- warnings.append(str(exc))
- return {"cypher": cypher, "confidence": 0.0, "usedSchema": req.allowedLabels, "warnings": warnings}
- except HTTPException:
- raise
- except Exception as exc:
- raise HTTPException(status_code=503, detail=f"Neo4j GraphRAG Cypher generation unavailable: {exc}") from exc
- @app.post("/answer")
- def answer(req: AnswerRequest):
- if not _llm_configured():
- raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
- try:
- from openai import OpenAI
- client = OpenAI(
- api_key=os.environ["OPENAI_API_KEY"],
- base_url=SETTINGS["llm"]["base_url"],
- timeout=_client_timeout(),
- max_retries=0,
- )
- evidence_json = json.dumps(req.evidences[:20], ensure_ascii=False, default=str)
- response = client.chat.completions.create(
- model=SETTINGS["llm"]["model"],
- messages=[
- {"role": "system", "content": "你是多源RAG回答助手。只能依据给定证据作答,明确区分文档、结构化数据和图谱依据;证据不足时直说,不得编造。回答使用简体中文。"},
- {"role": "user", "content": f"问题:{req.question}\n\n证据:{evidence_json}"},
- ],
- temperature=SETTINGS["llm"]["temperature"],
- max_tokens=SETTINGS["llm"]["answer_max_tokens"],
- )
- return {"answer": response.choices[0].message.content or "", "warnings": []}
- except Exception as exc:
- raise HTTPException(status_code=503, detail=f"Answer generation unavailable: {exc}") from exc
- @app.post("/governance/suggest")
- def governance_suggest(req: GovernanceRequest):
- if not _llm_configured(): raise HTTPException(status_code=503, detail="LLM is not configured")
- prompt = f"""Analyze the real {req.sourceType} schema and propose a safe business-facing RAG policy.
- Return JSON only with keys: summary, allowedLabels, allowedRelationships, allowedProperties,
- tableWhitelist, documentation, businessRules, examples. examples must contain question plus sql or cypher.
- Never invent schema names. Prefer a coherent business subgraph over opening unrelated technical metadata.
- Generate at most {req.maxExamples} representative reviewed-candidate examples covering lookup, filtering,
- aggregation or graph traversal as applicable.
- Source description: {req.sourceDescription}
- Schema:\n{req.schemaText}"""
- try:
- response = _openai_client(12).chat.completions.create(model=SETTINGS["llm"]["model"],
- messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"],
- max_tokens=SETTINGS["llm"]["answer_max_tokens"])
- return _json_object(response.choices[0].message.content or "")
- except Exception as exc:
- raise HTTPException(status_code=503, detail=f"governance suggestion unavailable: {exc}") from exc
- @app.post("/plan")
- def plan(req: PlanRequest):
- if not _llm_configured(): raise HTTPException(status_code=503, detail="LLM is not configured")
- prompt = f"""Split the user question into source-specific retrieval questions. Return JSON only:
- {{"DOCUMENT":"...","STRUCTURED_DATA":"...","GRAPH":"..."}}.
- Omit sources that cannot contribute according to their capability description. Preserve the user's intent.
- Question: {req.question}\nCapabilities: {json.dumps(req.capabilities, ensure_ascii=False)}"""
- try:
- response = _openai_client(12).chat.completions.create(model=SETTINGS["llm"]["model"],
- messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"], max_tokens=1024)
- return _json_object(response.choices[0].message.content or "")
- except Exception as exc:
- raise HTTPException(status_code=503, detail=f"question planning unavailable: {exc}") from exc
- @app.post("/repair")
- def repair(req: RepairRequest):
- language = req.language.upper()
- if language == "SQL":
- context = json.dumps({
- "dialect": req.dialect or "mysql",
- "selectedTables": req.selectedTables,
- "minimalDdl": req.schemaText,
- "foreignKeys": req.foreignKeys,
- "relationships": req.relationships,
- "sampledValues": req.sampledValues,
- "referenceRows": req.referenceRows,
- }, ensure_ascii=False, default=str)
- prompt = f"""Repair this read-only MySQL query after EXPLAIN failed. Return only one corrected SQL statement.
- Use only tables and columns in minimalDdl, only explicitly provided relationships, and only sampled/reference values.
- Every CONDITIONAL relationship must include all listed conditions. Avoid cartesian products.
- Never invent business mappings, schema identifiers, values or answers. Preserve AND semantics for simultaneous conditions.
- For highest/best by a stated score use ORDER BY that score DESC. Do not use PostgreSQL DISTINCT ON.
- Question: {req.question}
- Verified context: {context}
- Failed query: {req.query}
- Error: {_short_error(req.error)}
- Maximum rows: {req.maxRows}."""
- else:
- prompt = f"""Repair this read-only {language} query after EXPLAIN failed. Return only the corrected query.
- Use only the supplied schema. Make exactly one statement. Do not use write operations.
- 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).
- NEVER invent, translate, pluralize, or guess labels from the question text; every label/relationship MUST appear verbatim in the Schema.
- Every node pattern MUST declare exactly one label from the Schema — write (p:Person), never (p) or ().
- Question: {req.question}\nSchema: {req.schemaText}\nFailed query: {req.query}\nError: {_short_error(req.error)}
- Maximum rows: {req.maxRows}; maximum graph depth: {req.maxDepth}."""
- try:
- response = _openai_client(SETTINGS["llm"]["timeout"]).chat.completions.create(model=SETTINGS["llm"]["model"],
- messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"],
- max_tokens=SETTINGS["llm"]["generation_max_tokens"])
- fixed = _read_only(_extract_code(response.choices[0].message.content or "", language), language)
- return {"query": fixed}
- except Exception as exc:
- raise HTTPException(status_code=503, detail=f"query repair unavailable: {exc}") from exc
- if __name__ == "__main__":
- import uvicorn
- uvicorn.run(app, host=str(SETTINGS["server"]["host"]), port=int(SETTINGS["server"]["port"]))
|