Initial: Neo4j + FastAPI + APOC import + Azure OpenAI endpoint
This commit is contained in:
commit
6641a0c5bb
|
@ -0,0 +1,21 @@
|
|||
# secrets & local config
|
||||
.env
|
||||
.env.*
|
||||
.envrc
|
||||
|
||||
# Neo4j local data (if you add these later, keep them ignored)
|
||||
neo4j/
|
||||
neo4j/data/
|
||||
neo4j/logs/
|
||||
neo4j/plugins/
|
||||
neo4j/config/
|
||||
neo4j/import/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Editor/OS noise
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
|
@ -0,0 +1,18 @@
|
|||
# Neo4j + FastAPI Demo
|
||||
|
||||
## Run (local)
|
||||
1) Copy `.env.example` to `.env` and fill secrets.
|
||||
2) `docker compose up -d --build`
|
||||
3) API: http://localhost:8000/docs
|
||||
4) Neo4j Browser: http://localhost:7474
|
||||
|
||||
## Endpoints
|
||||
- POST `/echo`
|
||||
- GET `/messages`
|
||||
- GET `/messages/{id}`, PUT `/messages/{id}`, DELETE `/messages/{id}`
|
||||
- POST `/import/messages` (APOC CSV import)
|
||||
- POST `/ai/echo` (Azure OpenAI; stores reply in Neo4j)
|
||||
|
||||
## Notes
|
||||
- `.env` is **not** committed. Use `.env.example` as template.
|
||||
- Neo4j data is ignored (`neo4j/data`, `neo4j/logs`, etc).
|
|
@ -0,0 +1,26 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
# OS deps (optional but nice to have)
|
||||
#RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tini wget && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# App env
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps first (better layer caching)
|
||||
COPY api/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
# Copy app
|
||||
COPY api /app
|
||||
|
||||
# Non-root user
|
||||
RUN useradd -m appuser
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/usr/bin/tini","--"]
|
||||
CMD ["uvicorn","app:app","--host","0.0.0.0","--port","8000"]
|
|
@ -0,0 +1,256 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
from neo4j import GraphDatabase
|
||||
from typing import Optional
|
||||
import os, requests
|
||||
|
||||
NEO4J_URI = os.getenv("NEO4J_URI", "neo4j://neo4j:7687")
|
||||
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
|
||||
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
|
||||
|
||||
driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
|
||||
app = FastAPI()
|
||||
|
||||
class Inp(BaseModel):
|
||||
message: str
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
try:
|
||||
driver.verify_connectivity()
|
||||
except Exception:
|
||||
# DB might not be ready yet; endpoints will retry as needed
|
||||
pass
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown():
|
||||
driver.close()
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/echo")
|
||||
def echo(inp: Inp):
|
||||
reply = "hi hier gpt5-mini" if inp.message.strip().lower() == "hello world" else inp.message
|
||||
cypher = """
|
||||
CREATE (m:Message {input: $input, reply: $reply, created_at: datetime()})
|
||||
RETURN id(m) AS id
|
||||
"""
|
||||
records, _, _ = driver.execute_query(
|
||||
cypher,
|
||||
input=inp.message,
|
||||
reply=reply,
|
||||
database_="neo4j",
|
||||
)
|
||||
return {"reply": reply, "node_id": records[0]["id"]}
|
||||
|
||||
|
||||
@app.get("/messages")
|
||||
def list_messages(limit: int = 20):
|
||||
cypher = """
|
||||
MATCH (m:Message)
|
||||
RETURN id(m) AS id, m.input AS input, m.reply AS reply, m.created_at AS created_at
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $limit
|
||||
"""
|
||||
try:
|
||||
records, _, _ = driver.execute_query(cypher, limit=limit, database_="neo4j")
|
||||
except Exception as e:
|
||||
# Neo4j not yet available or auth/conn issue
|
||||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {type(e).__name__}")
|
||||
items = []
|
||||
for r in records:
|
||||
created = r["created_at"]
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"input": r["input"],
|
||||
"reply": r["reply"],
|
||||
"created_at": str(created) if created is not None else None,
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
|
||||
|
||||
class UpdateMessage(BaseModel):
|
||||
input: Optional[str] = None
|
||||
reply: Optional[str] = None
|
||||
|
||||
@app.get("/messages/{id}")
|
||||
def get_message(id: int):
|
||||
cypher = """
|
||||
MATCH (m:Message) WHERE id(m) = $id
|
||||
RETURN id(m) AS id, m.input AS input, m.reply AS reply, m.created_at AS created_at
|
||||
"""
|
||||
try:
|
||||
records, _, _ = driver.execute_query(cypher, id=id, database_="neo4j")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {type(e).__name__}")
|
||||
if not records:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
r = records[0]
|
||||
return {
|
||||
"id": r["id"],
|
||||
"input": r["input"],
|
||||
"reply": r["reply"],
|
||||
"created_at": str(r["created_at"]) if r["created_at"] is not None else None,
|
||||
}
|
||||
|
||||
@app.put("/messages/{id}")
|
||||
def update_message(id: int, body: UpdateMessage):
|
||||
if body.input is None and body.reply is None:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
set_clauses = []
|
||||
params = {"id": id}
|
||||
if body.input is not None:
|
||||
set_clauses.append("m.input = $input")
|
||||
params["input"] = body.input
|
||||
if body.reply is not None:
|
||||
set_clauses.append("m.reply = $reply")
|
||||
params["reply"] = body.reply
|
||||
|
||||
cypher = f"""
|
||||
MATCH (m:Message) WHERE id(m) = $id
|
||||
SET {", ".join(set_clauses)}
|
||||
RETURN id(m) AS id, m.input AS input, m.reply AS reply, m.created_at AS created_at
|
||||
"""
|
||||
try:
|
||||
records, _, _ = driver.execute_query(cypher, **params, database_="neo4j")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {type(e).__name__}")
|
||||
if not records:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
r = records[0]
|
||||
return {
|
||||
"id": r["id"],
|
||||
"input": r["input"],
|
||||
"reply": r["reply"],
|
||||
"created_at": str(r["created_at"]) if r["created_at"] is not None else None,
|
||||
}
|
||||
|
||||
@app.delete("/messages/{id}")
|
||||
def delete_message(id: int):
|
||||
cypher = """
|
||||
MATCH (m:Message) WHERE id(m) = $id
|
||||
WITH m, properties(m) AS props
|
||||
DETACH DELETE m
|
||||
RETURN $id AS id, props AS deleted
|
||||
"""
|
||||
try:
|
||||
records, _, _ = driver.execute_query(cypher, id=id, database_="neo4j")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {type(e).__name__}")
|
||||
# If no records returned, node didn't exist
|
||||
if not records:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
r = records[0]
|
||||
# props may be None if there was no match; we already guard above
|
||||
return {
|
||||
"id": r["id"],
|
||||
"deleted": r["deleted"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
class ImportMessagesRequest(BaseModel):
|
||||
filename: str = "messages.csv" # must live in /import
|
||||
batch_size: int = 1000
|
||||
|
||||
@app.post("/import/messages")
|
||||
def import_messages(req: ImportMessagesRequest):
|
||||
fname = os.path.basename(req.filename)
|
||||
if not fname:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
if not (1 <= req.batch_size <= 100_000):
|
||||
raise HTTPException(status_code=400, detail="batch_size out of range (1..100000)")
|
||||
|
||||
file_url = f"file:///{fname}"
|
||||
|
||||
# IMPORTANT: all literal { } in the Cypher must be doubled inside an f-string
|
||||
cypher = f"""
|
||||
CALL apoc.periodic.iterate(
|
||||
'CALL apoc.load.csv($url,{{header:true}}) YIELD map RETURN map',
|
||||
'
|
||||
MERGE (m:Message {{
|
||||
input: map.input,
|
||||
reply: map.reply,
|
||||
created_at: datetime(map.created_at)
|
||||
}})
|
||||
ON CREATE SET m.imported_at = datetime()
|
||||
ON MATCH SET m.last_seen = datetime()
|
||||
',
|
||||
{{batchSize: {req.batch_size}, parallel: false, params: $params}}
|
||||
)
|
||||
YIELD batches, total, committedOperations, failedOperations, failedBatches, retries, updateStatistics
|
||||
RETURN batches, total, committedOperations, failedOperations, failedBatches, retries, updateStatistics
|
||||
"""
|
||||
|
||||
try:
|
||||
records, _, _ = driver.execute_query(
|
||||
cypher,
|
||||
params={"url": file_url},
|
||||
database_="neo4j",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Neo4j/APOC error: {type(e).__name__}: {e}")
|
||||
|
||||
r = records[0] if records else {}
|
||||
stats = r.get("updateStatistics", {}) if isinstance(r.get("updateStatistics"), dict) else {}
|
||||
created = stats.get("nodesCreated", 0)
|
||||
props = stats.get("propertiesSet", 0)
|
||||
return {
|
||||
"file": fname,
|
||||
"status": "created" if created > 0 else "up-to-date",
|
||||
"batches": r.get("batches"),
|
||||
"total": r.get("total"),
|
||||
"created": created,
|
||||
"propertiesSet": props,
|
||||
"updateStatistics": stats,
|
||||
}
|
||||
|
||||
|
||||
class AiInp(BaseModel):
|
||||
message: str
|
||||
|
||||
@app.post("/ai/echo")
|
||||
def ai_echo(inp: AiInp):
|
||||
msg = inp.message.strip()
|
||||
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
|
||||
api_key = os.getenv("AZURE_OPENAI_API_KEY")
|
||||
deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT")
|
||||
api_ver = os.getenv("AZURE_OPENAI_API_VERSION", "2024-10-21")
|
||||
|
||||
if not all([endpoint, api_key, deployment]):
|
||||
raise HTTPException(status_code=500, detail="Azure OpenAI env not configured")
|
||||
|
||||
url = f"{endpoint}/openai/deployments/{deployment}/chat/completions?api-version={api_ver}"
|
||||
payload = {
|
||||
"messages": [
|
||||
{"role":"system","content":"You are a terse rephraser."},
|
||||
{"role":"user","content": msg}
|
||||
],
|
||||
"temperature": 0.2
|
||||
}
|
||||
headers = {"Content-Type":"application/json","api-key": api_key}
|
||||
|
||||
try:
|
||||
r = requests.post(url, json=payload, headers=headers, timeout=30)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
reply = data["choices"][0]["message"]["content"].strip()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Azure OpenAI request failed: {type(e).__name__}: {e}")
|
||||
|
||||
# store in Neo4j (same as /echo)
|
||||
cypher = """
|
||||
CREATE (m:Message {input: $input, reply: $reply, created_at: datetime()})
|
||||
RETURN id(m) AS id
|
||||
"""
|
||||
try:
|
||||
records, _, _ = driver.execute_query(cypher, input=msg, reply=reply, database_="neo4j")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {type(e).__name__}")
|
||||
|
||||
return {"reply": reply, "node_id": records[0]["id"]}
|
|
@ -0,0 +1,5 @@
|
|||
fastapi
|
||||
uvicorn
|
||||
neo4j
|
||||
pydantic
|
||||
requests
|
|
@ -0,0 +1,56 @@
|
|||
services:
|
||||
neo4j:
|
||||
image: "neo4j:${NEO4J_TAG}"
|
||||
container_name: neo4j
|
||||
user: "${UID}:${GID}"
|
||||
environment:
|
||||
- "NEO4J_AUTH=neo4j/${NEO4J_PASSWORD}"
|
||||
- 'NEO4J_PLUGINS=["apoc","apoc-extended"]'
|
||||
# Allow APOC procedures (5.x: allowlist/unrestricted)
|
||||
- "NEO4J_dbms_security_procedures_unrestricted=apoc.*"
|
||||
- "NEO4J_dbms_security_procedures_allowlist=apoc.*"
|
||||
# Optional but handy for file import/export via APOC
|
||||
- "NEO4J_apoc_export_file_enabled=true"
|
||||
- "NEO4J_apoc_import_file_enabled=true"
|
||||
- "NEO4J_apoc_import_file_use__neo4j__config=true"
|
||||
- "NEO4J_server_directories_import=/import"
|
||||
ports:
|
||||
- "7474:7474" # HTTP / Browser
|
||||
- "7687:7687" # Bolt
|
||||
volumes:
|
||||
- "${HOME}/neo4j/data:/data"
|
||||
- "${HOME}/neo4j/logs:/logs"
|
||||
- "${HOME}/neo4j/plugins:/plugins"
|
||||
- "${HOME}/neo4j/config:/config"
|
||||
- "${HOME}/neo4j/import:/import"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:7474"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: api/Dockerfile
|
||||
container_name: neo4j-api
|
||||
environment:
|
||||
- NEO4J_PASSWORD=${NEO4J_PASSWORD}
|
||||
- NEO4J_URI=${NEO4J_URI}
|
||||
- NEO4J_USER=${NEO4J_USER}
|
||||
- AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT}
|
||||
- AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY}
|
||||
- AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT}
|
||||
- AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION}
|
||||
ports:
|
||||
- "${API_PORT}:8000"
|
||||
depends_on:
|
||||
- neo4j
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8000/docs"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 10s
|
Loading…
Reference in New Issue