Spaces:
Sleeping
Sleeping
Upload 56 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env +29 -0
- .env.example +25 -0
- .gitignore +44 -0
- Dockerfile +28 -0
- app/__init__.py +0 -0
- app/__pycache__/__init__.cpython-312.pyc +0 -0
- app/__pycache__/config.cpython-312.pyc +0 -0
- app/__pycache__/main.cpython-312.pyc +0 -0
- app/agent/__init__.py +0 -0
- app/agent/__pycache__/__init__.cpython-312.pyc +0 -0
- app/agent/__pycache__/graph.cpython-312.pyc +0 -0
- app/agent/__pycache__/llm.cpython-312.pyc +0 -0
- app/agent/__pycache__/prompts.cpython-312.pyc +0 -0
- app/agent/__pycache__/state.cpython-312.pyc +0 -0
- app/agent/graph.py +117 -0
- app/agent/llm.py +30 -0
- app/agent/prompts.py +44 -0
- app/agent/state.py +16 -0
- app/config.py +47 -0
- app/db/__init__.py +0 -0
- app/db/__pycache__/__init__.cpython-312.pyc +0 -0
- app/db/__pycache__/neo4j.cpython-312.pyc +0 -0
- app/db/__pycache__/postgres.cpython-312.pyc +0 -0
- app/db/neo4j.py +49 -0
- app/db/postgres.py +49 -0
- app/deps.py +11 -0
- app/main.py +51 -0
- app/memory/__init__.py +0 -0
- app/memory/__pycache__/__init__.cpython-312.pyc +0 -0
- app/memory/__pycache__/history_trimmer.cpython-312.pyc +0 -0
- app/memory/__pycache__/redis_checkpointer.cpython-312.pyc +0 -0
- app/memory/history_trimmer.py +78 -0
- app/memory/redis_checkpointer.py +38 -0
- app/schemas/__init__.py +0 -0
- app/schemas/__pycache__/__init__.cpython-312.pyc +0 -0
- app/schemas/__pycache__/request.cpython-312.pyc +0 -0
- app/schemas/__pycache__/response.cpython-312.pyc +0 -0
- app/schemas/request.py +5 -0
- app/schemas/response.py +14 -0
- app/services/__pycache__/nlp_client.cpython-312.pyc +0 -0
- app/services/nlp_client.py +80 -0
- app/tools/__init__.py +0 -0
- app/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- app/tools/__pycache__/article_tool.cpython-312.pyc +0 -0
- app/tools/__pycache__/graph_tool.cpython-312.pyc +0 -0
- app/tools/__pycache__/sql_tool.cpython-312.pyc +0 -0
- app/tools/article_tool.py +49 -0
- app/tools/graph_tool.py +177 -0
- app/tools/hybrid_tool.py +131 -0
- app/tools/sql_tool.py +322 -0
.env
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- LLM ---
|
| 2 |
+
GROQ_API_KEY=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
|
| 3 |
+
|
| 4 |
+
# --- Internal service auth (shared secret with Django) ---
|
| 5 |
+
CHAT_SERVICE_INTERNAL_TOKEN=XoR_B5BiwcEDimWeL-a_kIqe-WcmC79wMg4FnRlMQXI
|
| 6 |
+
|
| 7 |
+
# --- Postgres (read-only role) ---
|
| 8 |
+
POSTGRES_READONLY_URL=postgresql://chat_ro_user:strong_password@hayabusa.proxy.rlwy.net:42420/railway?sslmode=require
|
| 9 |
+
|
| 10 |
+
# --- Neo4j ---
|
| 11 |
+
NEO4J_URI=neo4j+s://78f4ecfc.databases.neo4j.io
|
| 12 |
+
NEO4J_USER=78f4ecfc
|
| 13 |
+
NEO4J_PASSWORD=0SS0p8cU5f_cwxbcF9fErEkyNp6xEuhZC_wqcEjG06A
|
| 14 |
+
|
| 15 |
+
# --- Redis (reuse existing instance, separate DB index) ---
|
| 16 |
+
REDIS_URL=redis://default:UzTsAxeHPgOQUKPjDSvlxkdMcRVoJalU@hayabusa.proxy.rlwy.net:56307
|
| 17 |
+
|
| 18 |
+
# --- Chat session behavior ---
|
| 19 |
+
CHAT_SESSION_TTL_SECONDS=86400
|
| 20 |
+
CHAT_HISTORY_MAX_TURNS=10
|
| 21 |
+
|
| 22 |
+
# --- App ---
|
| 23 |
+
ENV=development
|
| 24 |
+
LOG_LEVEL=info
|
| 25 |
+
PORT=8002
|
| 26 |
+
|
| 27 |
+
# --- NLP ---
|
| 28 |
+
NLP_SERVICE_URL=https://AbdelazizAushar-arabic-nlp-service.hf.space
|
| 29 |
+
NLP_SERVICE_TOKEN="aNs-2026-nlp-key-final"
|
.env.example
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- LLM ---
|
| 2 |
+
GROQ_API_KEY=
|
| 3 |
+
|
| 4 |
+
# --- Internal service auth (shared secret with Django) ---
|
| 5 |
+
CHAT_SERVICE_INTERNAL_TOKEN=
|
| 6 |
+
|
| 7 |
+
# --- Postgres (read-only role) ---
|
| 8 |
+
POSTGRES_READONLY_URL=postgresql://chat_ro_user:CHANGEME@localhost:5432/newsdb
|
| 9 |
+
|
| 10 |
+
# --- Neo4j (read-only role) ---
|
| 11 |
+
NEO4J_READONLY_URI=neo4j://localhost:7687
|
| 12 |
+
NEO4J_READONLY_USER=chat_ro_user
|
| 13 |
+
NEO4J_READONLY_PASSWORD=CHANGEME
|
| 14 |
+
|
| 15 |
+
# --- Redis (reuse existing instance, separate DB index) ---
|
| 16 |
+
REDIS_URL=redis://localhost:6379/1
|
| 17 |
+
|
| 18 |
+
# --- Chat session behavior ---
|
| 19 |
+
CHAT_SESSION_TTL_SECONDS=86400
|
| 20 |
+
CHAT_HISTORY_MAX_TURNS=10
|
| 21 |
+
|
| 22 |
+
# --- App ---
|
| 23 |
+
ENV=development
|
| 24 |
+
LOG_LEVEL=info
|
| 25 |
+
PORT=8002
|
.gitignore
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# # --- Environment / secrets ---
|
| 2 |
+
# .env
|
| 3 |
+
# .env.*
|
| 4 |
+
# !.env.example
|
| 5 |
+
|
| 6 |
+
# --- Python ---
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*$py.class
|
| 10 |
+
*.so
|
| 11 |
+
.Python
|
| 12 |
+
venv/
|
| 13 |
+
env/
|
| 14 |
+
ENV/
|
| 15 |
+
.venv/
|
| 16 |
+
pip-log.txt
|
| 17 |
+
pip-delete-this-directory.txt
|
| 18 |
+
|
| 19 |
+
# --- Testing / coverage ---
|
| 20 |
+
.pytest_cache/
|
| 21 |
+
.coverage
|
| 22 |
+
htmlcov/
|
| 23 |
+
.tox/
|
| 24 |
+
|
| 25 |
+
# --- IDE ---
|
| 26 |
+
.vscode/
|
| 27 |
+
.idea/
|
| 28 |
+
*.swp
|
| 29 |
+
|
| 30 |
+
# --- OS ---
|
| 31 |
+
.DS_Store
|
| 32 |
+
Thumbs.db
|
| 33 |
+
|
| 34 |
+
# --- Logs ---
|
| 35 |
+
*.log
|
| 36 |
+
logs/
|
| 37 |
+
|
| 38 |
+
# --- Docker ---
|
| 39 |
+
*.pid
|
| 40 |
+
|
| 41 |
+
# --- Build artifacts ---
|
| 42 |
+
build/
|
| 43 |
+
dist/
|
| 44 |
+
*.egg-info/
|
Dockerfile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
# Prevent Python from writing .pyc files and enable unbuffered logs
|
| 4 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 5 |
+
PYTHONUNBUFFERED=1
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# System deps needed for psycopg / neo4j drivers to build
|
| 10 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 11 |
+
build-essential \
|
| 12 |
+
libpq-dev \
|
| 13 |
+
curl \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
# Install Python deps first (layer caching)
|
| 17 |
+
COPY requirements.txt .
|
| 18 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
# Copy application code
|
| 21 |
+
COPY . .
|
| 22 |
+
|
| 23 |
+
EXPOSE 8000
|
| 24 |
+
|
| 25 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
| 26 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 27 |
+
|
| 28 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
app/__init__.py
ADDED
|
File without changes
|
app/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (157 Bytes). View file
|
|
|
app/__pycache__/config.cpython-312.pyc
ADDED
|
Binary file (1.52 kB). View file
|
|
|
app/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (2.55 kB). View file
|
|
|
app/agent/__init__.py
ADDED
|
File without changes
|
app/agent/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (163 Bytes). View file
|
|
|
app/agent/__pycache__/graph.cpython-312.pyc
ADDED
|
Binary file (4.81 kB). View file
|
|
|
app/agent/__pycache__/llm.cpython-312.pyc
ADDED
|
Binary file (1.33 kB). View file
|
|
|
app/agent/__pycache__/prompts.cpython-312.pyc
ADDED
|
Binary file (2.61 kB). View file
|
|
|
app/agent/__pycache__/state.cpython-312.pyc
ADDED
|
Binary file (948 Bytes). View file
|
|
|
app/agent/graph.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from app.memory.history_trimmer import maybe_trim_history
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
from typing import Any, Optional
|
| 6 |
+
|
| 7 |
+
from langchain_groq import ChatGroq
|
| 8 |
+
from langgraph.prebuilt import create_react_agent
|
| 9 |
+
from langgraph.checkpoint.redis.aio import AsyncRedisSaver
|
| 10 |
+
|
| 11 |
+
from app.agent.prompts import SYSTEM_PROMPT
|
| 12 |
+
from app.agent.state import AgentState
|
| 13 |
+
from app.config import get_settings
|
| 14 |
+
from app.tools.graph_tool import graph_query_tool
|
| 15 |
+
from app.tools.sql_tool import sql_query_tool
|
| 16 |
+
from app.tools.article_tool import get_article_detail
|
| 17 |
+
from app.tools.hybrid_tool import hybrid_search_tool
|
| 18 |
+
|
| 19 |
+
DEFAULT_MODEL = "llama-3.3-70b-versatile"
|
| 20 |
+
|
| 21 |
+
_agent = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def build_agent(checkpointer: AsyncRedisSaver):
|
| 25 |
+
"""Called once at startup after the checkpointer is ready."""
|
| 26 |
+
global _agent
|
| 27 |
+
settings = get_settings()
|
| 28 |
+
llm = ChatGroq(
|
| 29 |
+
api_key=settings.groq_api_key,
|
| 30 |
+
model=DEFAULT_MODEL,
|
| 31 |
+
temperature=0,
|
| 32 |
+
)
|
| 33 |
+
_agent = create_react_agent(
|
| 34 |
+
model=llm,
|
| 35 |
+
tools=[sql_query_tool, graph_query_tool, hybrid_search_tool],
|
| 36 |
+
state_schema=AgentState,
|
| 37 |
+
prompt=SYSTEM_PROMPT,
|
| 38 |
+
checkpointer=checkpointer,
|
| 39 |
+
)
|
| 40 |
+
return _agent
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_agent():
|
| 44 |
+
if _agent is None:
|
| 45 |
+
raise RuntimeError(
|
| 46 |
+
"Agent not initialized — call build_agent() at startup first")
|
| 47 |
+
return _agent
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _extract_sources(messages: list) -> list[dict]:
|
| 51 |
+
sources: list[dict] = []
|
| 52 |
+
for m in messages:
|
| 53 |
+
if getattr(m, "type", None) != "tool":
|
| 54 |
+
continue
|
| 55 |
+
try:
|
| 56 |
+
payload = json.loads(m.content) if isinstance(
|
| 57 |
+
m.content, str) else m.content
|
| 58 |
+
except (json.JSONDecodeError, TypeError):
|
| 59 |
+
continue
|
| 60 |
+
refs = payload.get("source_refs") if isinstance(
|
| 61 |
+
payload, dict) else None
|
| 62 |
+
if refs:
|
| 63 |
+
sources.extend(refs)
|
| 64 |
+
|
| 65 |
+
seen: set[tuple] = set()
|
| 66 |
+
deduped: list[dict] = []
|
| 67 |
+
for s in sources:
|
| 68 |
+
key = (s.get("type"), s.get("id"))
|
| 69 |
+
if key not in seen:
|
| 70 |
+
seen.add(key)
|
| 71 |
+
deduped.append(s)
|
| 72 |
+
return deduped
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def _enrich_sources(raw_refs: list[dict]) -> list[dict]:
|
| 76 |
+
enriched: list[dict] = []
|
| 77 |
+
for ref in raw_refs:
|
| 78 |
+
if ref.get("type") == "article" and ref.get("id") is not None:
|
| 79 |
+
detail = await get_article_detail(int(ref["id"]))
|
| 80 |
+
rows = detail.get("rows") or []
|
| 81 |
+
if rows:
|
| 82 |
+
article = rows[0]
|
| 83 |
+
enriched.append({
|
| 84 |
+
"type": "article",
|
| 85 |
+
"id": article["id"],
|
| 86 |
+
"title": article.get("title"),
|
| 87 |
+
"url": article.get("url"),
|
| 88 |
+
})
|
| 89 |
+
continue
|
| 90 |
+
enriched.append(ref)
|
| 91 |
+
return enriched
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
async def run_agent(message: str, session_id: str, user_id: Optional[str]) -> dict[str, Any]:
|
| 95 |
+
agent = get_agent()
|
| 96 |
+
config = {"configurable": {"thread_id": session_id}}
|
| 97 |
+
|
| 98 |
+
result = await agent.ainvoke(
|
| 99 |
+
{
|
| 100 |
+
"messages": [{"role": "user", "content": message}],
|
| 101 |
+
"sources": [],
|
| 102 |
+
"session_id": session_id,
|
| 103 |
+
"user_id": user_id,
|
| 104 |
+
},
|
| 105 |
+
config=config,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
final_message = result["messages"][-1]
|
| 109 |
+
answer = final_message.content if isinstance(
|
| 110 |
+
final_message.content, str) else str(final_message.content)
|
| 111 |
+
|
| 112 |
+
raw_sources = _extract_sources(result["messages"])
|
| 113 |
+
sources = await _enrich_sources(raw_sources)
|
| 114 |
+
|
| 115 |
+
await maybe_trim_history(agent, config)
|
| 116 |
+
|
| 117 |
+
return {"answer": answer, "sources": sources}
|
app/agent/llm.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from groq import Groq
|
| 2 |
+
|
| 3 |
+
from app.config import get_settings
|
| 4 |
+
|
| 5 |
+
_client: Groq | None = None
|
| 6 |
+
|
| 7 |
+
DEFAULT_MODEL = "llama-3.3-70b-versatile"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _get_client() -> Groq:
|
| 11 |
+
global _client
|
| 12 |
+
if _client is None:
|
| 13 |
+
settings = get_settings()
|
| 14 |
+
_client = Groq(api_key=settings.groq_api_key)
|
| 15 |
+
return _client
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def ask(prompt: str, model: str = DEFAULT_MODEL) -> str:
|
| 19 |
+
"""
|
| 20 |
+
Minimal single-turn call to Groq. No tools, no memory —
|
| 21 |
+
just proves the API key + connection work end-to-end.
|
| 22 |
+
"""
|
| 23 |
+
client = _get_client()
|
| 24 |
+
|
| 25 |
+
response = client.chat.completions.create(
|
| 26 |
+
model=model,
|
| 27 |
+
messages=[{"role": "user", "content": prompt}],
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
return response.choices[0].message.content or ""
|
app/agent/prompts.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SYSTEM_PROMPT = """\
|
| 2 |
+
You are the assistant for a news analysis platform. You help users understand \
|
| 3 |
+
statistics and findings about analyzed news articles: fake-news verdicts, \
|
| 4 |
+
topics, sentiment, propaganda techniques, hate-speech categories, dialect, \
|
| 5 |
+
and entity relationships extracted from those articles.
|
| 6 |
+
|
| 7 |
+
## Tool use
|
| 8 |
+
- You have access to `sql_query_tool`, which runs pre-defined, parameterized \
|
| 9 |
+
queries against a read-only database of analyzed articles (stats, counts, \
|
| 10 |
+
breakdowns, lookups).
|
| 11 |
+
- You have access to `graph_query_tool`, which runs pre-defined Cypher queries \
|
| 12 |
+
against a read-only entity relationship graph (connections between entities, \
|
| 13 |
+
which articles mention an entity, shared entities between articles, most \
|
| 14 |
+
mentioned entities, fact-check verdicts, per-claim breakdowns, and full \
|
| 15 |
+
per-article analysis profiles).
|
| 16 |
+
- Use whichever tool matches the question — `sql_query_tool` for statistics/ \
|
| 17 |
+
counts/breakdowns, `graph_query_tool` for relationships/connections/fact-check \
|
| 18 |
+
detail. Do not answer such questions from memory.
|
| 19 |
+
- Never invent numbers, article titles, verdicts, or entity names. If a tool \
|
| 20 |
+
does not return the information needed to answer, say so plainly instead of \
|
| 21 |
+
guessing.
|
| 22 |
+
- If the user asks about "last month", "this week", "yesterday", etc., convert \
|
| 23 |
+
that into concrete ISO `date_from` / `date_to` values yourself before calling \
|
| 24 |
+
`sql_query_tool`, using today's date as reference.
|
| 25 |
+
- If a question is ambiguous about which metric or query_type applies, pick \
|
| 26 |
+
the closest matching one and state the assumption you made in your answer.
|
| 27 |
+
- If a question asks about something no available tool covers (e.g. general \
|
| 28 |
+
knowledge, opinions, anything outside this platform's analyzed article data), \
|
| 29 |
+
say clearly that you cannot answer it rather than fabricating a response.
|
| 30 |
+
|
| 31 |
+
## Citations
|
| 32 |
+
- Every factual claim in your answer that is based on tool results must be \
|
| 33 |
+
traceable to a specific article. Mention every `article_id` you actually used \
|
| 34 |
+
to construct your answer — do not omit any, and do not include IDs you did not use.
|
| 35 |
+
- Do not fabricate article IDs, titles, or entity names that were not returned \
|
| 36 |
+
by a tool call. If a tool returned no matching data, say so — do not guess.
|
| 37 |
+
|
| 38 |
+
## Language
|
| 39 |
+
- Always respond in the same language the user wrote their message in.
|
| 40 |
+
|
| 41 |
+
## Style
|
| 42 |
+
- Be direct and concise. Give the number or fact requested first, then brief \
|
| 43 |
+
supporting context if useful. Do not pad answers with unnecessary caveats.
|
| 44 |
+
"""
|
app/agent/state.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Annotated, TypedDict
|
| 4 |
+
|
| 5 |
+
from langchain_core.messages import BaseMessage
|
| 6 |
+
from langgraph.graph.message import add_messages
|
| 7 |
+
from langgraph.managed.is_last_step import RemainingSteps
|
| 8 |
+
from typing_extensions import NotRequired
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AgentState(TypedDict):
|
| 12 |
+
messages: Annotated[list[BaseMessage], add_messages]
|
| 13 |
+
sources: list[dict]
|
| 14 |
+
session_id: str
|
| 15 |
+
user_id: str | None
|
| 16 |
+
remaining_steps: NotRequired[RemainingSteps]
|
app/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
|
| 3 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Settings(BaseSettings):
|
| 7 |
+
model_config = SettingsConfigDict(
|
| 8 |
+
env_file=".env",
|
| 9 |
+
env_file_encoding="utf-8",
|
| 10 |
+
extra="ignore",
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
# --- App ---
|
| 14 |
+
env: str = "development"
|
| 15 |
+
log_level: str = "info"
|
| 16 |
+
port: int = 8002
|
| 17 |
+
|
| 18 |
+
# --- LLM ---
|
| 19 |
+
groq_api_key: str
|
| 20 |
+
|
| 21 |
+
# --- Internal service auth (shared secret with Django) ---
|
| 22 |
+
chat_service_internal_token: str = ""
|
| 23 |
+
|
| 24 |
+
# --- Postgres (read-only role) ---
|
| 25 |
+
postgres_readonly_url: str = ""
|
| 26 |
+
|
| 27 |
+
# --- Neo4j (Aura Free — no RBAC, enforcement is app-level only) ---
|
| 28 |
+
neo4j_uri: str = ""
|
| 29 |
+
neo4j_user: str = ""
|
| 30 |
+
neo4j_password: str = ""
|
| 31 |
+
|
| 32 |
+
# --- Redis ---
|
| 33 |
+
redis_url: str = ""
|
| 34 |
+
|
| 35 |
+
# --- Chat session behavior ---
|
| 36 |
+
chat_session_ttl_seconds: int = 86400
|
| 37 |
+
chat_history_max_turns: int = 10
|
| 38 |
+
|
| 39 |
+
# --- NLP EMBED ---
|
| 40 |
+
nlp_service_url: str = ""
|
| 41 |
+
nlp_service_token: str = ""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@lru_cache
|
| 45 |
+
def get_settings() -> Settings:
|
| 46 |
+
"""Cached settings instance — reads .env once per process."""
|
| 47 |
+
return Settings()
|
app/db/__init__.py
ADDED
|
File without changes
|
app/db/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (160 Bytes). View file
|
|
|
app/db/__pycache__/neo4j.cpython-312.pyc
ADDED
|
Binary file (2.72 kB). View file
|
|
|
app/db/__pycache__/postgres.cpython-312.pyc
ADDED
|
Binary file (2.2 kB). View file
|
|
|
app/db/neo4j.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from neo4j import AsyncGraphDatabase, AsyncDriver, AsyncTransaction
|
| 4 |
+
|
| 5 |
+
from app.config import get_settings
|
| 6 |
+
|
| 7 |
+
_driver: AsyncDriver | None = None
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_driver() -> AsyncDriver:
|
| 11 |
+
global _driver
|
| 12 |
+
if _driver is None:
|
| 13 |
+
settings = get_settings()
|
| 14 |
+
_driver = AsyncGraphDatabase.driver(
|
| 15 |
+
settings.neo4j_uri,
|
| 16 |
+
auth=(settings.neo4j_user, settings.neo4j_password),
|
| 17 |
+
)
|
| 18 |
+
return _driver
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def verify_connectivity() -> None:
|
| 22 |
+
"""Call on app startup to fail fast if Aura is unreachable."""
|
| 23 |
+
await get_driver().verify_connectivity()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def close_driver() -> None:
|
| 27 |
+
global _driver
|
| 28 |
+
if _driver is not None:
|
| 29 |
+
await _driver.close()
|
| 30 |
+
_driver = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def run_read_query(query: str, **params) -> list[dict]:
|
| 34 |
+
"""
|
| 35 |
+
Runs a Cypher query inside an explicit READ transaction.
|
| 36 |
+
|
| 37 |
+
App-level read-only enforcement (Aura Free has no RBAC/custom roles,
|
| 38 |
+
unlike the Postgres chat_ro_user). NEVER pass write Cypher
|
| 39 |
+
(CREATE/MERGE/SET/DELETE/REMOVE) into this function — query text must
|
| 40 |
+
only come from pre-written templates in tool code, never from the LLM.
|
| 41 |
+
"""
|
| 42 |
+
driver = get_driver()
|
| 43 |
+
|
| 44 |
+
async def _tx_func(tx: AsyncTransaction):
|
| 45 |
+
result = await tx.run(query, **params)
|
| 46 |
+
return await result.data()
|
| 47 |
+
|
| 48 |
+
async with driver.session() as session:
|
| 49 |
+
return await session.execute_read(_tx_func)
|
app/db/postgres.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
|
| 5 |
+
from psycopg_pool import AsyncConnectionPool
|
| 6 |
+
|
| 7 |
+
from app.config import get_settings
|
| 8 |
+
|
| 9 |
+
_pool: AsyncConnectionPool | None = None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_pool() -> AsyncConnectionPool:
|
| 13 |
+
global _pool
|
| 14 |
+
if _pool is None:
|
| 15 |
+
settings = get_settings()
|
| 16 |
+
_pool = AsyncConnectionPool(
|
| 17 |
+
conninfo=settings.postgres_readonly_url,
|
| 18 |
+
min_size=1,
|
| 19 |
+
max_size=10,
|
| 20 |
+
open=False,
|
| 21 |
+
)
|
| 22 |
+
return _pool
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def open_pool() -> None:
|
| 26 |
+
"""Call on app startup."""
|
| 27 |
+
pool = get_pool()
|
| 28 |
+
if pool.closed:
|
| 29 |
+
await pool.open(wait=True)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def close_pool() -> None:
|
| 33 |
+
"""Call on app shutdown."""
|
| 34 |
+
global _pool
|
| 35 |
+
if _pool is not None and not _pool.closed:
|
| 36 |
+
await _pool.close()
|
| 37 |
+
_pool = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@asynccontextmanager
|
| 41 |
+
async def get_readonly_connection():
|
| 42 |
+
"""
|
| 43 |
+
Yields a connection with an extra read-only guard set at the
|
| 44 |
+
session level, on top of the chat_ro_user DB role.
|
| 45 |
+
"""
|
| 46 |
+
pool = get_pool()
|
| 47 |
+
async with pool.connection() as conn:
|
| 48 |
+
await conn.execute("SET default_transaction_read_only = on")
|
| 49 |
+
yield conn
|
app/deps.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Annotated
|
| 2 |
+
|
| 3 |
+
from fastapi import Depends
|
| 4 |
+
|
| 5 |
+
from app.agent.graph import get_agent
|
| 6 |
+
from app.config import Settings, get_settings
|
| 7 |
+
|
| 8 |
+
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
AgentDep = Annotated[object, Depends(get_agent)]
|
app/main.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
|
| 5 |
+
from app.agent.graph import run_agent
|
| 6 |
+
from app.config import get_settings
|
| 7 |
+
from app.db.postgres import close_pool, open_pool
|
| 8 |
+
from app.db.neo4j import verify_connectivity, close_driver
|
| 9 |
+
from app.schemas.request import ChatRequest
|
| 10 |
+
from app.schemas.response import ChatResponse
|
| 11 |
+
from app.memory.redis_checkpointer import get_checkpointer, close_checkpointer
|
| 12 |
+
from app.agent.graph import build_agent
|
| 13 |
+
|
| 14 |
+
settings = get_settings()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@asynccontextmanager
|
| 18 |
+
async def lifespan(app: FastAPI):
|
| 19 |
+
await open_pool()
|
| 20 |
+
await verify_connectivity()
|
| 21 |
+
checkpointer = await get_checkpointer()
|
| 22 |
+
build_agent(checkpointer)
|
| 23 |
+
yield
|
| 24 |
+
await close_pool()
|
| 25 |
+
await close_driver()
|
| 26 |
+
await close_checkpointer()
|
| 27 |
+
|
| 28 |
+
app = FastAPI(title="chat-service", version="0.1.0", lifespan=lifespan)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@app.get("/health")
|
| 32 |
+
def health() -> dict:
|
| 33 |
+
return {"status": "ok"}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@app.post("/chat", response_model=ChatResponse)
|
| 37 |
+
async def chat(payload: ChatRequest) -> ChatResponse:
|
| 38 |
+
"""
|
| 39 |
+
Milestone 2: routes through the LangGraph ReAct agent, which can call
|
| 40 |
+
sql_query_tool to answer questions grounded in the analyzed-articles DB.
|
| 41 |
+
"""
|
| 42 |
+
result = await run_agent(
|
| 43 |
+
message=payload.message,
|
| 44 |
+
session_id=payload.session_id,
|
| 45 |
+
user_id=payload.user_id,
|
| 46 |
+
)
|
| 47 |
+
return ChatResponse(
|
| 48 |
+
session_id=payload.session_id,
|
| 49 |
+
answer=result["answer"],
|
| 50 |
+
sources=result["sources"],
|
| 51 |
+
)
|
app/memory/__init__.py
ADDED
|
File without changes
|
app/memory/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (164 Bytes). View file
|
|
|
app/memory/__pycache__/history_trimmer.cpython-312.pyc
ADDED
|
Binary file (4.2 kB). View file
|
|
|
app/memory/__pycache__/redis_checkpointer.cpython-312.pyc
ADDED
|
Binary file (1.81 kB). View file
|
|
|
app/memory/history_trimmer.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from langchain_core.messages import BaseMessage, RemoveMessage, SystemMessage
|
| 4 |
+
from langchain_groq import ChatGroq
|
| 5 |
+
|
| 6 |
+
from app.config import get_settings
|
| 7 |
+
|
| 8 |
+
_summarizer_llm = None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _get_summarizer() -> ChatGroq:
|
| 12 |
+
"""Separate, cheaper model just for summarization — keeps main agent's
|
| 13 |
+
token budget free and avoids competing for the same Groq quota."""
|
| 14 |
+
global _summarizer_llm
|
| 15 |
+
if _summarizer_llm is None:
|
| 16 |
+
settings = get_settings()
|
| 17 |
+
_summarizer_llm = ChatGroq(
|
| 18 |
+
api_key=settings.groq_api_key,
|
| 19 |
+
model="llama-3.1-8b-instant",
|
| 20 |
+
temperature=0,
|
| 21 |
+
)
|
| 22 |
+
return _summarizer_llm
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _is_turn_message(m: BaseMessage) -> bool:
|
| 26 |
+
"""Only human/ai messages count as a 'turn' — tool/system noise is excluded."""
|
| 27 |
+
return getattr(m, "type", None) in ("human", "ai")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def maybe_trim_history(agent, config: dict) -> None:
|
| 31 |
+
"""
|
| 32 |
+
Keeps the last CHAT_HISTORY_MAX_TURNS human/ai messages verbatim in
|
| 33 |
+
the checkpointed state. Anything older is collapsed into a single
|
| 34 |
+
SystemMessage summary. Call this after every agent.ainvoke().
|
| 35 |
+
"""
|
| 36 |
+
settings = get_settings()
|
| 37 |
+
max_turns = settings.chat_history_max_turns
|
| 38 |
+
|
| 39 |
+
state = await agent.aget_state(config)
|
| 40 |
+
messages: list[BaseMessage] = state.values.get("messages", [])
|
| 41 |
+
|
| 42 |
+
turn_messages = [m for m in messages if _is_turn_message(m)]
|
| 43 |
+
if len(turn_messages) <= max_turns:
|
| 44 |
+
return # nothing to trim yet
|
| 45 |
+
|
| 46 |
+
cutoff_id = turn_messages[-max_turns].id
|
| 47 |
+
cutoff_index = next(i for i, m in enumerate(messages) if m.id == cutoff_id)
|
| 48 |
+
old_messages = [m for m in messages[:cutoff_index] if m.id is not None]
|
| 49 |
+
|
| 50 |
+
if not old_messages:
|
| 51 |
+
return
|
| 52 |
+
|
| 53 |
+
transcript = "\n".join(
|
| 54 |
+
f"{getattr(m, 'type', '?')}: {m.content}"
|
| 55 |
+
for m in old_messages
|
| 56 |
+
if isinstance(getattr(m, "content", None), str)
|
| 57 |
+
)
|
| 58 |
+
if not transcript.strip():
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
summarizer = _get_summarizer()
|
| 62 |
+
summary_resp = await summarizer.ainvoke([
|
| 63 |
+
SystemMessage(content=(
|
| 64 |
+
"Summarize this conversation history in 2-4 concise sentences. "
|
| 65 |
+
"Preserve key facts, entities, dates, and figures the user asked "
|
| 66 |
+
"about — this summary replaces the original messages as context "
|
| 67 |
+
"for future turns."
|
| 68 |
+
)),
|
| 69 |
+
SystemMessage(content=transcript),
|
| 70 |
+
])
|
| 71 |
+
|
| 72 |
+
summary_message = SystemMessage(
|
| 73 |
+
content=f"[Earlier conversation summary]: {summary_resp.content}",
|
| 74 |
+
additional_kwargs={"is_history_summary": True},
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
removals = [RemoveMessage(id=m.id) for m in old_messages]
|
| 78 |
+
await agent.aupdate_state(config, {"messages": removals + [summary_message]})
|
app/memory/redis_checkpointer.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from langgraph.checkpoint.redis.aio import AsyncRedisSaver
|
| 4 |
+
|
| 5 |
+
from app.config import get_settings
|
| 6 |
+
|
| 7 |
+
_checkpointer_cm = None
|
| 8 |
+
_checkpointer: AsyncRedisSaver | None = None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def get_checkpointer() -> AsyncRedisSaver:
|
| 12 |
+
"""
|
| 13 |
+
Call once at app startup. Returns a live AsyncRedisSaver.
|
| 14 |
+
TTL is set from CHAT_SESSION_TTL_SECONDS (converted to minutes,
|
| 15 |
+
library expects minutes). refresh_on_read keeps active sessions alive.
|
| 16 |
+
"""
|
| 17 |
+
global _checkpointer_cm, _checkpointer
|
| 18 |
+
if _checkpointer is None:
|
| 19 |
+
settings = get_settings()
|
| 20 |
+
ttl_minutes = max(1, settings.chat_session_ttl_seconds // 60)
|
| 21 |
+
|
| 22 |
+
_checkpointer_cm = AsyncRedisSaver.from_conn_string(
|
| 23 |
+
settings.redis_url,
|
| 24 |
+
ttl={"default_ttl": ttl_minutes, "refresh_on_read": True},
|
| 25 |
+
checkpoint_prefix="chat:checkpoint",
|
| 26 |
+
checkpoint_write_prefix="chat:checkpoint_write",
|
| 27 |
+
)
|
| 28 |
+
_checkpointer = await _checkpointer_cm.__aenter__()
|
| 29 |
+
await _checkpointer.asetup() # creates Redis search indices, idempotent
|
| 30 |
+
return _checkpointer
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def close_checkpointer() -> None:
|
| 34 |
+
global _checkpointer_cm, _checkpointer
|
| 35 |
+
if _checkpointer_cm is not None:
|
| 36 |
+
await _checkpointer_cm.__aexit__(None, None, None)
|
| 37 |
+
_checkpointer = None
|
| 38 |
+
_checkpointer_cm = None
|
app/schemas/__init__.py
ADDED
|
File without changes
|
app/schemas/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (165 Bytes). View file
|
|
|
app/schemas/__pycache__/request.cpython-312.pyc
ADDED
|
Binary file (587 Bytes). View file
|
|
|
app/schemas/__pycache__/response.cpython-312.pyc
ADDED
|
Binary file (822 Bytes). View file
|
|
|
app/schemas/request.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
class ChatRequest(BaseModel):
|
| 3 |
+
session_id: str
|
| 4 |
+
message: str = Field(..., min_length=1)
|
| 5 |
+
user_id: str | None = None
|
app/schemas/response.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class SourceRef(BaseModel):
|
| 5 |
+
type: str
|
| 6 |
+
id: int
|
| 7 |
+
title: str | None = None
|
| 8 |
+
url: str | None = None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ChatResponse(BaseModel):
|
| 12 |
+
session_id: str
|
| 13 |
+
answer: str
|
| 14 |
+
sources: list[SourceRef] = []
|
app/services/__pycache__/nlp_client.cpython-312.pyc
ADDED
|
Binary file (4.1 kB). View file
|
|
|
app/services/nlp_client.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from app.config import get_settings
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
FAISS_DIM = 384 # match Django's MiniLM output dim (NewsArticle.search_vector)
|
| 15 |
+
|
| 16 |
+
_NLP_MAX_RETRIES = 3
|
| 17 |
+
_NLP_TIMEOUT_S = 60.0 # generous — accounts for HF Space cold start
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _nlp_service_url() -> str:
|
| 21 |
+
settings = get_settings()
|
| 22 |
+
url = settings.nlp_service_url.rstrip("/")
|
| 23 |
+
if not url:
|
| 24 |
+
raise RuntimeError("NLP_SERVICE_URL not configured in settings")
|
| 25 |
+
return url
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _nlp_headers() -> dict[str, str]:
|
| 29 |
+
settings = get_settings()
|
| 30 |
+
if not settings.nlp_service_token:
|
| 31 |
+
raise RuntimeError("NLP_SERVICE_TOKEN not configured in settings")
|
| 32 |
+
return {
|
| 33 |
+
"Authorization": f"Bearer {settings.nlp_service_token}",
|
| 34 |
+
"Content-Type": "application/json",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def _nlp_post(path: str, payload: dict) -> Any:
|
| 39 |
+
"""POST to the shared NLP microservice with retry on timeout/5xx (cold-start safe)."""
|
| 40 |
+
url = f"{_nlp_service_url()}{path}"
|
| 41 |
+
headers = _nlp_headers()
|
| 42 |
+
last_exc: Exception | None = None
|
| 43 |
+
|
| 44 |
+
async with httpx.AsyncClient(timeout=_NLP_TIMEOUT_S) as client:
|
| 45 |
+
for attempt in range(1, _NLP_MAX_RETRIES + 1):
|
| 46 |
+
try:
|
| 47 |
+
resp = await client.post(url, json=payload, headers=headers)
|
| 48 |
+
resp.raise_for_status()
|
| 49 |
+
return resp.json()
|
| 50 |
+
except (httpx.TimeoutException, httpx.ConnectError) as exc:
|
| 51 |
+
last_exc = exc
|
| 52 |
+
logger.warning(
|
| 53 |
+
"NLP service attempt %d/%d timed out: %s",
|
| 54 |
+
attempt, _NLP_MAX_RETRIES, exc,
|
| 55 |
+
)
|
| 56 |
+
if attempt < _NLP_MAX_RETRIES:
|
| 57 |
+
await asyncio.sleep(2 ** attempt) # 2s, 4s
|
| 58 |
+
except httpx.HTTPStatusError as exc:
|
| 59 |
+
if exc.response.status_code < 500:
|
| 60 |
+
raise # 4xx — don't retry
|
| 61 |
+
last_exc = exc
|
| 62 |
+
logger.warning(
|
| 63 |
+
"NLP service attempt %d/%d HTTP error: %s",
|
| 64 |
+
attempt, _NLP_MAX_RETRIES, exc,
|
| 65 |
+
)
|
| 66 |
+
if attempt < _NLP_MAX_RETRIES:
|
| 67 |
+
await asyncio.sleep(2 ** attempt)
|
| 68 |
+
|
| 69 |
+
raise RuntimeError(
|
| 70 |
+
f"NLP microservice unavailable after {_NLP_MAX_RETRIES} attempts"
|
| 71 |
+
) from last_exc
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
async def embed(texts: list[str]) -> np.ndarray:
|
| 75 |
+
"""
|
| 76 |
+
Embed texts via the shared remote MiniLM endpoint (same HF Space Django uses).
|
| 77 |
+
Returns float32 array of shape (len(texts), FAISS_DIM), L2-normalised.
|
| 78 |
+
"""
|
| 79 |
+
result: list[list[float]] = await _nlp_post("/embed", {"texts": texts})
|
| 80 |
+
return np.array(result, dtype="float32")
|
app/tools/__init__.py
ADDED
|
File without changes
|
app/tools/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (163 Bytes). View file
|
|
|
app/tools/__pycache__/article_tool.cpython-312.pyc
ADDED
|
Binary file (2.26 kB). View file
|
|
|
app/tools/__pycache__/graph_tool.cpython-312.pyc
ADDED
|
Binary file (8.79 kB). View file
|
|
|
app/tools/__pycache__/sql_tool.cpython-312.pyc
ADDED
|
Binary file (14.3 kB). View file
|
|
|
app/tools/article_tool.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.db.postgres import get_readonly_connection
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def get_article_detail(article_id: int) -> dict:
|
| 7 |
+
"""
|
| 8 |
+
Resolves a single article's full title/snippet/metadata for citation
|
| 9 |
+
display. Used by the agent post-processing step to enrich raw
|
| 10 |
+
article_id references into full source objects.
|
| 11 |
+
|
| 12 |
+
Returns: {"rows": [...], "source_refs": [{"type": "article", "id": ...}]}
|
| 13 |
+
(kept in the same shape as sql_tool/graph_tool for consistency, even
|
| 14 |
+
though this always returns 0 or 1 row.)
|
| 15 |
+
"""
|
| 16 |
+
query = """
|
| 17 |
+
SELECT id, title, url, published_at, content
|
| 18 |
+
FROM news_articles
|
| 19 |
+
WHERE id = %s
|
| 20 |
+
"""
|
| 21 |
+
async with get_readonly_connection() as conn:
|
| 22 |
+
cur = await conn.execute(query, (article_id,))
|
| 23 |
+
row = await cur.fetchone()
|
| 24 |
+
|
| 25 |
+
if row is None:
|
| 26 |
+
return {"rows": [], "source_refs": []}
|
| 27 |
+
|
| 28 |
+
columns = [desc[0] for desc in cur.description]
|
| 29 |
+
article = dict(zip(columns, row))
|
| 30 |
+
|
| 31 |
+
# Truncate content into a short snippet — don't return full article text
|
| 32 |
+
# for a citation lookup, keep responses lean
|
| 33 |
+
snippet = (article.get("content") or "")[:280]
|
| 34 |
+
if len(article.get("content") or "") > 280:
|
| 35 |
+
snippet += "..."
|
| 36 |
+
|
| 37 |
+
return {
|
| 38 |
+
"rows": [{
|
| 39 |
+
"id": article["id"],
|
| 40 |
+
"title": article["title"],
|
| 41 |
+
"url": article["url"],
|
| 42 |
+
"published_at": (
|
| 43 |
+
article["published_at"].isoformat()
|
| 44 |
+
if article.get("published_at") else None
|
| 45 |
+
),
|
| 46 |
+
"snippet": snippet,
|
| 47 |
+
}],
|
| 48 |
+
"source_refs": [{"type": "article", "id": article["id"]}],
|
| 49 |
+
}
|
app/tools/graph_tool.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any, Literal, Optional
|
| 5 |
+
|
| 6 |
+
from langchain_core.tools import tool
|
| 7 |
+
|
| 8 |
+
from app.db.neo4j import run_read_query
|
| 9 |
+
|
| 10 |
+
QueryType = Literal[
|
| 11 |
+
"entity_connections",
|
| 12 |
+
"entity_mentions",
|
| 13 |
+
"shared_entities_between_articles",
|
| 14 |
+
"most_connected_entities",
|
| 15 |
+
"article_verdict",
|
| 16 |
+
"claims_for_article",
|
| 17 |
+
"analysis_for_article",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def _entity_connections(entity_name, limit):
|
| 22 |
+
query = """
|
| 23 |
+
MATCH (e:Entity {name: $entity_name})-[r]-(other)
|
| 24 |
+
RETURN other, type(r) AS relationship, labels(other) AS other_labels
|
| 25 |
+
LIMIT $limit
|
| 26 |
+
"""
|
| 27 |
+
rows = await run_read_query(query, entity_name=entity_name, limit=limit)
|
| 28 |
+
|
| 29 |
+
refs = []
|
| 30 |
+
for row in rows:
|
| 31 |
+
other = row.get("other", {}) or {}
|
| 32 |
+
if "entity_id" in other:
|
| 33 |
+
refs.append({"type": "entity", "id": other["entity_id"]})
|
| 34 |
+
elif other.get("article_id") is not None:
|
| 35 |
+
refs.append({"type": "article", "id": other["article_id"]})
|
| 36 |
+
return rows, refs
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def _entity_mentions(entity_name, limit):
|
| 40 |
+
query = """
|
| 41 |
+
MATCH (a:Article)-[r:MENTIONS]->(e:Entity {name: $entity_name})
|
| 42 |
+
RETURN a.article_id AS article_id, r.confidence AS confidence
|
| 43 |
+
ORDER BY r.confidence DESC
|
| 44 |
+
LIMIT $limit
|
| 45 |
+
"""
|
| 46 |
+
rows = await run_read_query(query, entity_name=entity_name, limit=limit)
|
| 47 |
+
refs = [{"type": "article", "id": r["article_id"]} for r in rows if r.get("article_id") is not None]
|
| 48 |
+
return rows, refs
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def _shared_entities_between_articles(article_id, article_id_2, limit):
|
| 52 |
+
query = """
|
| 53 |
+
MATCH (a1:Article {article_id: $article_id})-[:MENTIONS]->(e:Entity)
|
| 54 |
+
<-[:MENTIONS]-(a2:Article {article_id: $article_id_2})
|
| 55 |
+
RETURN e.name AS name, e.type AS type, e.entity_id AS entity_id
|
| 56 |
+
LIMIT $limit
|
| 57 |
+
"""
|
| 58 |
+
rows = await run_read_query(query, article_id=article_id, article_id_2=article_id_2, limit=limit)
|
| 59 |
+
refs = [{"type": "article", "id": article_id}, {"type": "article", "id": article_id_2}]
|
| 60 |
+
refs += [{"type": "entity", "id": r["entity_id"]} for r in rows if r.get("entity_id")]
|
| 61 |
+
return rows, refs
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def _most_connected_entities(limit):
|
| 65 |
+
query = """
|
| 66 |
+
MATCH (e:Entity)<-[:MENTIONS]-(a:Article)
|
| 67 |
+
RETURN e.name AS name, e.type AS type, e.entity_id AS entity_id,
|
| 68 |
+
count(a) AS mention_count
|
| 69 |
+
ORDER BY mention_count DESC
|
| 70 |
+
LIMIT $limit
|
| 71 |
+
"""
|
| 72 |
+
rows = await run_read_query(query, limit=limit)
|
| 73 |
+
refs = [{"type": "entity", "id": r["entity_id"]} for r in rows if r.get("entity_id")]
|
| 74 |
+
return rows, refs
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
async def _article_verdict(article_id):
|
| 78 |
+
query = """
|
| 79 |
+
MATCH (a:Article {article_id: $article_id})-[:HAS_FINAL_VERDICT]->(v:Verdict)
|
| 80 |
+
OPTIONAL MATCH (v)-[:BASED_ON]->(e:Evidence)
|
| 81 |
+
RETURN v.verdict AS verdict, v.confidence AS confidence,
|
| 82 |
+
v.explanation AS explanation, collect(e) AS evidence
|
| 83 |
+
"""
|
| 84 |
+
rows = await run_read_query(query, article_id=article_id)
|
| 85 |
+
return rows, [{"type": "article", "id": article_id}]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def _claims_for_article(article_id):
|
| 89 |
+
query = """
|
| 90 |
+
MATCH (a:Article {article_id: $article_id})-[:PROPOSES]->(c:Claim)
|
| 91 |
+
-[:EVALUATED_BY]->(v:Verdict)
|
| 92 |
+
RETURN c.text AS claim_text, c.claim_idx AS claim_idx,
|
| 93 |
+
v.verdict AS verdict, v.confidence AS confidence,
|
| 94 |
+
v.explanation AS explanation
|
| 95 |
+
ORDER BY c.claim_idx
|
| 96 |
+
"""
|
| 97 |
+
rows = await run_read_query(query, article_id=article_id)
|
| 98 |
+
return rows, [{"type": "article", "id": article_id}]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def _analysis_for_article(article_id):
|
| 102 |
+
query = """
|
| 103 |
+
MATCH (a:Article {article_id: $article_id})-[r]->(an:Analysis)
|
| 104 |
+
RETURN type(r) AS relationship, an.analysis_type AS analysis_type,
|
| 105 |
+
an.value AS value, an.score AS score
|
| 106 |
+
"""
|
| 107 |
+
rows = await run_read_query(query, article_id=article_id)
|
| 108 |
+
return rows, [{"type": "article", "id": article_id}]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@tool
|
| 112 |
+
async def graph_query_tool(
|
| 113 |
+
query_type: QueryType,
|
| 114 |
+
entity_name: Optional[str] = None,
|
| 115 |
+
article_id: Optional[int] = None,
|
| 116 |
+
article_id_2: Optional[int] = None,
|
| 117 |
+
limit: int = 20,
|
| 118 |
+
) -> str:
|
| 119 |
+
"""
|
| 120 |
+
Run a pre-defined, read-only Cypher query over the Neo4j entity/article
|
| 121 |
+
relationship graph.
|
| 122 |
+
|
| 123 |
+
query_type: which relationship query to run —
|
| 124 |
+
- entity_connections: everything connected to entity_name, any
|
| 125 |
+
relationship direction (mentions + co-mentions)
|
| 126 |
+
- entity_mentions: which articles mention entity_name
|
| 127 |
+
- shared_entities_between_articles: entities mentioned in both
|
| 128 |
+
article_id and article_id_2
|
| 129 |
+
- most_connected_entities: global ranking of most-mentioned entities
|
| 130 |
+
(no entity_name needed)
|
| 131 |
+
- article_verdict: final fake-news verdict + evidence for article_id
|
| 132 |
+
- claims_for_article: per-claim verdict breakdown for article_id
|
| 133 |
+
- analysis_for_article: full sentiment/topic/dialect/propaganda/
|
| 134 |
+
hate-speech profile for article_id
|
| 135 |
+
|
| 136 |
+
entity_name: match by Entity.name (NOT internal entity_id). Required for
|
| 137 |
+
entity_connections, entity_mentions, shared_entities_between_articles.
|
| 138 |
+
article_id / article_id_2: required for article_verdict, claims_for_article,
|
| 139 |
+
analysis_for_article, and shared_entities_between_articles (needs both).
|
| 140 |
+
limit: max rows to return, ignored for single-article lookups.
|
| 141 |
+
|
| 142 |
+
Returns a JSON string: {"rows": [...], "source_refs": [{"type": ...,
|
| 143 |
+
"id": ...}, ...]}
|
| 144 |
+
"""
|
| 145 |
+
try:
|
| 146 |
+
if query_type == "entity_connections":
|
| 147 |
+
if not entity_name:
|
| 148 |
+
raise ValueError("entity_connections requires entity_name")
|
| 149 |
+
rows, refs = await _entity_connections(entity_name, limit)
|
| 150 |
+
elif query_type == "entity_mentions":
|
| 151 |
+
if not entity_name:
|
| 152 |
+
raise ValueError("entity_mentions requires entity_name")
|
| 153 |
+
rows, refs = await _entity_mentions(entity_name, limit)
|
| 154 |
+
elif query_type == "shared_entities_between_articles":
|
| 155 |
+
if article_id is None or article_id_2 is None:
|
| 156 |
+
raise ValueError("shared_entities_between_articles requires article_id and article_id_2")
|
| 157 |
+
rows, refs = await _shared_entities_between_articles(article_id, article_id_2, limit)
|
| 158 |
+
elif query_type == "most_connected_entities":
|
| 159 |
+
rows, refs = await _most_connected_entities(limit)
|
| 160 |
+
elif query_type == "article_verdict":
|
| 161 |
+
if article_id is None:
|
| 162 |
+
raise ValueError("article_verdict requires article_id")
|
| 163 |
+
rows, refs = await _article_verdict(article_id)
|
| 164 |
+
elif query_type == "claims_for_article":
|
| 165 |
+
if article_id is None:
|
| 166 |
+
raise ValueError("claims_for_article requires article_id")
|
| 167 |
+
rows, refs = await _claims_for_article(article_id)
|
| 168 |
+
elif query_type == "analysis_for_article":
|
| 169 |
+
if article_id is None:
|
| 170 |
+
raise ValueError("analysis_for_article requires article_id")
|
| 171 |
+
rows, refs = await _analysis_for_article(article_id)
|
| 172 |
+
else:
|
| 173 |
+
return json.dumps({"rows": [], "source_refs": [], "error": f"Unknown query_type: {query_type}"})
|
| 174 |
+
except ValueError as exc:
|
| 175 |
+
return json.dumps({"rows": [], "source_refs": [], "error": str(exc)})
|
| 176 |
+
|
| 177 |
+
return json.dumps({"rows": rows, "source_refs": refs}, default=str)
|
app/tools/hybrid_tool.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from langchain_core.tools import tool
|
| 7 |
+
|
| 8 |
+
from app.db.neo4j import run_read_query
|
| 9 |
+
from app.db.postgres import get_readonly_connection
|
| 10 |
+
from app.services.nlp_client import embed
|
| 11 |
+
|
| 12 |
+
# Must match FAISS_DIM / MiniLM output dim used by nlp_client.embed()
|
| 13 |
+
EMBEDDING_DIM = 384
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _to_vector_literal(vec) -> str:
|
| 17 |
+
"""
|
| 18 |
+
Format an embedding as a pgvector literal, e.g. '[0.1,0.2,...]'.
|
| 19 |
+
No pgvector adapter is registered on the psycopg pool (see
|
| 20 |
+
app/db/postgres.py), so we build the literal ourselves and cast
|
| 21 |
+
with ::vector in SQL rather than relying on parameter binding.
|
| 22 |
+
"""
|
| 23 |
+
return "[" + ",".join(f"{float(x):.8f}" for x in vec) + "]"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def _semantic_search(query_text: str, limit: int) -> list[dict[str, Any]]:
|
| 27 |
+
"""Step 1 + 2: embed query_text, then pgvector cosine similarity search."""
|
| 28 |
+
vectors = await embed([query_text])
|
| 29 |
+
vector_literal = _to_vector_literal(vectors[0])
|
| 30 |
+
|
| 31 |
+
sql = """
|
| 32 |
+
SELECT id, title, url, published_at, content,
|
| 33 |
+
(search_vector <=> %s::vector) AS distance
|
| 34 |
+
FROM news_articles
|
| 35 |
+
WHERE search_vector IS NOT NULL
|
| 36 |
+
ORDER BY distance ASC
|
| 37 |
+
LIMIT %s
|
| 38 |
+
"""
|
| 39 |
+
async with get_readonly_connection() as conn:
|
| 40 |
+
cur = await conn.execute(sql, (vector_literal, limit))
|
| 41 |
+
rows = await cur.fetchall()
|
| 42 |
+
columns = [desc[0] for desc in cur.description]
|
| 43 |
+
|
| 44 |
+
articles = []
|
| 45 |
+
for row in rows:
|
| 46 |
+
record = dict(zip(columns, row))
|
| 47 |
+
content = record.pop("content", None) or ""
|
| 48 |
+
snippet = content[:280] + ("..." if len(content) > 280 else "")
|
| 49 |
+
distance = record.pop("distance")
|
| 50 |
+
articles.append({
|
| 51 |
+
"id": record["id"],
|
| 52 |
+
"title": record["title"],
|
| 53 |
+
"url": record["url"],
|
| 54 |
+
"published_at": (
|
| 55 |
+
record["published_at"].isoformat()
|
| 56 |
+
if record.get("published_at") else None
|
| 57 |
+
),
|
| 58 |
+
"snippet": snippet,
|
| 59 |
+
# cosine distance -> similarity; pgvector's <=> is cosine
|
| 60 |
+
# distance under the vector_cosine_ops opclass
|
| 61 |
+
"similarity": round(1 - distance, 4),
|
| 62 |
+
})
|
| 63 |
+
return articles
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def _entities_for_article(article_id: int, limit: int) -> list[dict[str, Any]]:
|
| 67 |
+
"""Step 3: pull mentioned entities from Neo4j for one article."""
|
| 68 |
+
query = """
|
| 69 |
+
MATCH (a:Article {article_id: $article_id})-[:MENTIONS]->(e:Entity)
|
| 70 |
+
RETURN e.name AS name, e.type AS type, e.entity_id AS entity_id
|
| 71 |
+
LIMIT $limit
|
| 72 |
+
"""
|
| 73 |
+
rows = await run_read_query(query, article_id=article_id, limit=limit)
|
| 74 |
+
return [r for r in rows if r.get("entity_id")]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@tool
|
| 78 |
+
async def hybrid_search_tool(query_text: str, limit: int = 10) -> str:
|
| 79 |
+
"""
|
| 80 |
+
Semantic search over article content, expanded with mentioned entities.
|
| 81 |
+
|
| 82 |
+
Use this when a question needs *finding* articles by meaning/topic
|
| 83 |
+
(not an exact stat or a known entity/article_id lookup) — e.g. "articles
|
| 84 |
+
about the election and who's mentioned in them", "coverage of the
|
| 85 |
+
drought", "find articles similar to X".
|
| 86 |
+
|
| 87 |
+
Steps:
|
| 88 |
+
1. Embed query_text (shared MiniLM endpoint, same as Django uses).
|
| 89 |
+
2. Cosine similarity search over news_articles.search_vector
|
| 90 |
+
(pgvector) to get the top-`limit` articles, ranked by similarity.
|
| 91 |
+
3. For each returned article, pull its mentioned entities from
|
| 92 |
+
Neo4j (:MENTIONS relationship).
|
| 93 |
+
4. Merge into one ranked result set with combined source_refs
|
| 94 |
+
(articles + entities).
|
| 95 |
+
|
| 96 |
+
query_text: free-text description of what to search for.
|
| 97 |
+
limit: max number of articles to return (entities per article are
|
| 98 |
+
capped separately and not counted against this).
|
| 99 |
+
|
| 100 |
+
Returns a JSON string: {"rows": [{"id", "title", "url", "published_at",
|
| 101 |
+
"snippet", "similarity", "entities": [...]}], "source_refs": [{"type":
|
| 102 |
+
"article"|"entity", "id": ...}, ...]}
|
| 103 |
+
"""
|
| 104 |
+
try:
|
| 105 |
+
articles = await _semantic_search(query_text, limit)
|
| 106 |
+
except Exception as exc: # noqa: BLE001 - surface to agent, don't crash turn
|
| 107 |
+
return json.dumps({"rows": [], "source_refs": [], "error": str(exc)})
|
| 108 |
+
|
| 109 |
+
source_refs: list[dict[str, Any]] = []
|
| 110 |
+
seen_entity_ids: set[Any] = set()
|
| 111 |
+
|
| 112 |
+
for article in articles:
|
| 113 |
+
source_refs.append({"type": "article", "id": article["id"]})
|
| 114 |
+
try:
|
| 115 |
+
entities = await _entities_for_article(article["id"], limit=10)
|
| 116 |
+
except Exception: # noqa: BLE001 - one article's graph lookup failing shouldn't sink the rest
|
| 117 |
+
entities = []
|
| 118 |
+
|
| 119 |
+
article["entities"] = [
|
| 120 |
+
{"name": e.get("name"), "type": e.get("type"),
|
| 121 |
+
"entity_id": e.get("entity_id")}
|
| 122 |
+
for e in entities
|
| 123 |
+
]
|
| 124 |
+
for e in entities:
|
| 125 |
+
entity_id = e.get("entity_id")
|
| 126 |
+
if entity_id is not None and entity_id not in seen_entity_ids:
|
| 127 |
+
seen_entity_ids.add(entity_id)
|
| 128 |
+
source_refs.append({"type": "entity", "id": entity_id})
|
| 129 |
+
|
| 130 |
+
# already ranked by similarity from the SQL ORDER BY
|
| 131 |
+
return json.dumps({"rows": articles, "source_refs": source_refs}, default=str)
|
app/tools/sql_tool.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any, Literal, Optional
|
| 5 |
+
|
| 6 |
+
from langchain_core.tools import tool
|
| 7 |
+
from psycopg.rows import dict_row
|
| 8 |
+
|
| 9 |
+
from app.db.postgres import get_readonly_connection
|
| 10 |
+
|
| 11 |
+
Metric = Literal[
|
| 12 |
+
"fake_news_count",
|
| 13 |
+
"articles_by_topic",
|
| 14 |
+
"sentiment_breakdown",
|
| 15 |
+
"propaganda_count",
|
| 16 |
+
"hate_speech_count",
|
| 17 |
+
"dialect_breakdown",
|
| 18 |
+
"article_lookup",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def _fetch(sql: str, params: list[Any]) -> list[dict[str, Any]]:
|
| 23 |
+
async with get_readonly_connection() as conn:
|
| 24 |
+
async with conn.cursor(row_factory=dict_row) as cur:
|
| 25 |
+
await cur.execute(sql, params)
|
| 26 |
+
return await cur.fetchall()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _date_filter(where: list[str], params: list[Any], date_col: str,
|
| 30 |
+
date_from: Optional[str], date_to: Optional[str]) -> None:
|
| 31 |
+
if date_from:
|
| 32 |
+
where.append(f"{date_col} >= %s")
|
| 33 |
+
params.append(date_from)
|
| 34 |
+
if date_to:
|
| 35 |
+
where.append(f"{date_col} <= %s")
|
| 36 |
+
params.append(date_to)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def _fake_news_count(date_from, date_to, article_id, limit):
|
| 40 |
+
where: list[str] = []
|
| 41 |
+
params: list[Any] = []
|
| 42 |
+
_date_filter(where, params, "analyzed_at", date_from, date_to)
|
| 43 |
+
if article_id is not None:
|
| 44 |
+
where.append("article_id = %s")
|
| 45 |
+
params.append(article_id)
|
| 46 |
+
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
| 47 |
+
|
| 48 |
+
breakdown_sql = f"""
|
| 49 |
+
SELECT verdict, COUNT(*) AS count
|
| 50 |
+
FROM article_fake_news
|
| 51 |
+
{where_sql}
|
| 52 |
+
GROUP BY verdict
|
| 53 |
+
ORDER BY count DESC
|
| 54 |
+
"""
|
| 55 |
+
rows = await _fetch(breakdown_sql, params)
|
| 56 |
+
|
| 57 |
+
refs_sql = f"""
|
| 58 |
+
SELECT article_id
|
| 59 |
+
FROM article_fake_news
|
| 60 |
+
{where_sql}
|
| 61 |
+
ORDER BY analyzed_at DESC
|
| 62 |
+
LIMIT %s
|
| 63 |
+
"""
|
| 64 |
+
refs = await _fetch(refs_sql, params + [limit])
|
| 65 |
+
return breakdown_sql, rows, refs
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def _articles_by_topic(date_from, date_to, topic, article_id, limit):
|
| 69 |
+
where: list[str] = []
|
| 70 |
+
params: list[Any] = []
|
| 71 |
+
_date_filter(where, params, "analyzed_at", date_from, date_to)
|
| 72 |
+
if topic:
|
| 73 |
+
where.append("(primary_topic = %s OR secondary_topic = %s)")
|
| 74 |
+
params.extend([topic, topic])
|
| 75 |
+
if article_id is not None:
|
| 76 |
+
where.append("article_id = %s")
|
| 77 |
+
params.append(article_id)
|
| 78 |
+
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
| 79 |
+
|
| 80 |
+
breakdown_sql = f"""
|
| 81 |
+
SELECT primary_topic, COUNT(*) AS count
|
| 82 |
+
FROM article_topic
|
| 83 |
+
{where_sql}
|
| 84 |
+
GROUP BY primary_topic
|
| 85 |
+
ORDER BY count DESC
|
| 86 |
+
"""
|
| 87 |
+
rows = await _fetch(breakdown_sql, params)
|
| 88 |
+
|
| 89 |
+
refs_sql = f"""
|
| 90 |
+
SELECT article_id
|
| 91 |
+
FROM article_topic
|
| 92 |
+
{where_sql}
|
| 93 |
+
ORDER BY analyzed_at DESC
|
| 94 |
+
LIMIT %s
|
| 95 |
+
"""
|
| 96 |
+
refs = await _fetch(refs_sql, params + [limit])
|
| 97 |
+
return breakdown_sql, rows, refs
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def _sentiment_breakdown(date_from, date_to, sentiment, article_id, limit):
|
| 101 |
+
where: list[str] = []
|
| 102 |
+
params: list[Any] = []
|
| 103 |
+
_date_filter(where, params, "analyzed_at", date_from, date_to)
|
| 104 |
+
if sentiment:
|
| 105 |
+
where.append("primary_sentiment = %s")
|
| 106 |
+
params.append(sentiment)
|
| 107 |
+
if article_id is not None:
|
| 108 |
+
where.append("article_id = %s")
|
| 109 |
+
params.append(article_id)
|
| 110 |
+
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
| 111 |
+
|
| 112 |
+
breakdown_sql = f"""
|
| 113 |
+
SELECT primary_sentiment, COUNT(*) AS count
|
| 114 |
+
FROM article_sentiment
|
| 115 |
+
{where_sql}
|
| 116 |
+
GROUP BY primary_sentiment
|
| 117 |
+
ORDER BY count DESC
|
| 118 |
+
"""
|
| 119 |
+
rows = await _fetch(breakdown_sql, params)
|
| 120 |
+
|
| 121 |
+
refs_sql = f"""
|
| 122 |
+
SELECT article_id
|
| 123 |
+
FROM article_sentiment
|
| 124 |
+
{where_sql}
|
| 125 |
+
ORDER BY analyzed_at DESC
|
| 126 |
+
LIMIT %s
|
| 127 |
+
"""
|
| 128 |
+
refs = await _fetch(refs_sql, params + [limit])
|
| 129 |
+
return breakdown_sql, rows, refs
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def _propaganda_count(date_from, date_to, article_id, limit):
|
| 133 |
+
where: list[str] = []
|
| 134 |
+
params: list[Any] = []
|
| 135 |
+
_date_filter(where, params, "analyzed_at", date_from, date_to)
|
| 136 |
+
if article_id is not None:
|
| 137 |
+
where.append("article_id = %s")
|
| 138 |
+
params.append(article_id)
|
| 139 |
+
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
| 140 |
+
propaganda_where = where + ["is_propaganda = true"]
|
| 141 |
+
|
| 142 |
+
count_sql = f"""
|
| 143 |
+
SELECT COUNT(*) AS count
|
| 144 |
+
FROM article_propaganda
|
| 145 |
+
WHERE {' AND '.join(propaganda_where)}
|
| 146 |
+
"""
|
| 147 |
+
rows = await _fetch(count_sql, params)
|
| 148 |
+
|
| 149 |
+
technique_sql = f"""
|
| 150 |
+
SELECT technique, COUNT(*) AS count
|
| 151 |
+
FROM article_propaganda, unnest(techniques) AS technique
|
| 152 |
+
WHERE {' AND '.join(propaganda_where)}
|
| 153 |
+
GROUP BY technique
|
| 154 |
+
ORDER BY count DESC
|
| 155 |
+
LIMIT 10
|
| 156 |
+
"""
|
| 157 |
+
technique_rows = await _fetch(technique_sql, params)
|
| 158 |
+
rows[0]["technique_breakdown"] = technique_rows
|
| 159 |
+
|
| 160 |
+
refs_sql = f"""
|
| 161 |
+
SELECT article_id
|
| 162 |
+
FROM article_propaganda
|
| 163 |
+
WHERE {' AND '.join(propaganda_where)}
|
| 164 |
+
ORDER BY analyzed_at DESC
|
| 165 |
+
LIMIT %s
|
| 166 |
+
"""
|
| 167 |
+
refs = await _fetch(refs_sql, params + [limit])
|
| 168 |
+
return count_sql, rows, refs
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
async def _hate_speech_count(date_from, date_to, category, article_id, limit):
|
| 172 |
+
where: list[str] = []
|
| 173 |
+
params: list[Any] = []
|
| 174 |
+
_date_filter(where, params, "analyzed_at", date_from, date_to)
|
| 175 |
+
if category:
|
| 176 |
+
where.append("predicted_category = %s")
|
| 177 |
+
params.append(category)
|
| 178 |
+
if article_id is not None:
|
| 179 |
+
where.append("article_id = %s")
|
| 180 |
+
params.append(article_id)
|
| 181 |
+
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
| 182 |
+
|
| 183 |
+
breakdown_sql = f"""
|
| 184 |
+
SELECT predicted_category, COUNT(*) AS count
|
| 185 |
+
FROM article_hate_speech
|
| 186 |
+
{where_sql}
|
| 187 |
+
GROUP BY predicted_category
|
| 188 |
+
ORDER BY count DESC
|
| 189 |
+
"""
|
| 190 |
+
rows = await _fetch(breakdown_sql, params)
|
| 191 |
+
|
| 192 |
+
refs_sql = f"""
|
| 193 |
+
SELECT article_id
|
| 194 |
+
FROM article_hate_speech
|
| 195 |
+
{where_sql}
|
| 196 |
+
ORDER BY analyzed_at DESC
|
| 197 |
+
LIMIT %s
|
| 198 |
+
"""
|
| 199 |
+
refs = await _fetch(refs_sql, params + [limit])
|
| 200 |
+
return breakdown_sql, rows, refs
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
async def _dialect_breakdown(date_from, date_to, dialect, article_id, limit):
|
| 204 |
+
where: list[str] = []
|
| 205 |
+
params: list[Any] = []
|
| 206 |
+
_date_filter(where, params, "analyzed_at", date_from, date_to)
|
| 207 |
+
if dialect:
|
| 208 |
+
where.append("detected_dialect = %s")
|
| 209 |
+
params.append(dialect)
|
| 210 |
+
if article_id is not None:
|
| 211 |
+
where.append("article_id = %s")
|
| 212 |
+
params.append(article_id)
|
| 213 |
+
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
| 214 |
+
|
| 215 |
+
breakdown_sql = f"""
|
| 216 |
+
SELECT detected_dialect, COUNT(*) AS count
|
| 217 |
+
FROM article_dialect
|
| 218 |
+
{where_sql}
|
| 219 |
+
GROUP BY detected_dialect
|
| 220 |
+
ORDER BY count DESC
|
| 221 |
+
"""
|
| 222 |
+
rows = await _fetch(breakdown_sql, params)
|
| 223 |
+
|
| 224 |
+
refs_sql = f"""
|
| 225 |
+
SELECT article_id
|
| 226 |
+
FROM article_dialect
|
| 227 |
+
{where_sql}
|
| 228 |
+
ORDER BY analyzed_at DESC
|
| 229 |
+
LIMIT %s
|
| 230 |
+
"""
|
| 231 |
+
refs = await _fetch(refs_sql, params + [limit])
|
| 232 |
+
return breakdown_sql, rows, refs
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
async def _article_lookup(date_from, date_to, article_id, keyword, limit):
|
| 236 |
+
where: list[str] = []
|
| 237 |
+
params: list[Any] = []
|
| 238 |
+
_date_filter(where, params, "published_at", date_from, date_to)
|
| 239 |
+
if article_id is not None:
|
| 240 |
+
where.append("id = %s")
|
| 241 |
+
params.append(article_id)
|
| 242 |
+
if keyword:
|
| 243 |
+
where.append("title ILIKE %s")
|
| 244 |
+
params.append(f"%{keyword}%")
|
| 245 |
+
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
| 246 |
+
|
| 247 |
+
sql = f"""
|
| 248 |
+
SELECT id, title, url, published_at
|
| 249 |
+
FROM news_articles
|
| 250 |
+
{where_sql}
|
| 251 |
+
ORDER BY published_at DESC
|
| 252 |
+
LIMIT %s
|
| 253 |
+
"""
|
| 254 |
+
rows = await _fetch(sql, params + [limit])
|
| 255 |
+
refs = [{"article_id": r["id"]} for r in rows]
|
| 256 |
+
return sql, rows, refs
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
@tool
|
| 260 |
+
async def sql_query_tool(
|
| 261 |
+
metric: Metric,
|
| 262 |
+
date_from: Optional[str] = None,
|
| 263 |
+
date_to: Optional[str] = None,
|
| 264 |
+
topic: Optional[str] = None,
|
| 265 |
+
sentiment: Optional[str] = None,
|
| 266 |
+
category: Optional[str] = None,
|
| 267 |
+
dialect: Optional[str] = None,
|
| 268 |
+
article_id: Optional[int] = None,
|
| 269 |
+
keyword: Optional[str] = None,
|
| 270 |
+
limit: int = 20,
|
| 271 |
+
) -> str:
|
| 272 |
+
"""
|
| 273 |
+
Run a pre-defined, read-only query over analyzed news articles.
|
| 274 |
+
|
| 275 |
+
metric: which statistic/lookup to run —
|
| 276 |
+
- fake_news_count: breakdown of fake-news verdicts
|
| 277 |
+
(SUPPORTED, REFUTED, PARTIALLY_TRUE, UNVERIFIABLE)
|
| 278 |
+
- articles_by_topic: article counts grouped by primary topic
|
| 279 |
+
- sentiment_breakdown: article counts grouped by sentiment
|
| 280 |
+
(positive, negative, neutral)
|
| 281 |
+
- propaganda_count: count of articles flagged as propaganda,
|
| 282 |
+
plus a technique frequency breakdown
|
| 283 |
+
- hate_speech_count: article counts grouped by hate-speech category
|
| 284 |
+
(none, other, origin, gender, religion)
|
| 285 |
+
- dialect_breakdown: article counts grouped by detected dialect
|
| 286 |
+
- article_lookup: look up specific articles by id or title keyword
|
| 287 |
+
|
| 288 |
+
date_from / date_to: ISO dates (YYYY-MM-DD). For all metrics except
|
| 289 |
+
article_lookup, these filter on the analysis timestamp
|
| 290 |
+
(`analyzed_at`) — i.e. when the ML model ran, not when the article
|
| 291 |
+
was published. For article_lookup they filter on `published_at`.
|
| 292 |
+
topic, sentiment, category, dialect: optional exact-match filters for
|
| 293 |
+
the corresponding metric.
|
| 294 |
+
article_id: restrict to a single article.
|
| 295 |
+
keyword: for article_lookup, a substring to search for in the title.
|
| 296 |
+
limit: max number of source article references / lookup rows to return.
|
| 297 |
+
|
| 298 |
+
Returns a JSON string: {"rows": [...], "sql_used": "...",
|
| 299 |
+
"source_refs": [{"article_id": ...}, ...]}
|
| 300 |
+
"""
|
| 301 |
+
handlers = {
|
| 302 |
+
"fake_news_count": lambda: _fake_news_count(date_from, date_to, article_id, limit),
|
| 303 |
+
"articles_by_topic": lambda: _articles_by_topic(date_from, date_to, topic, article_id, limit),
|
| 304 |
+
"sentiment_breakdown": lambda: _sentiment_breakdown(date_from, date_to, sentiment, article_id, limit),
|
| 305 |
+
"propaganda_count": lambda: _propaganda_count(date_from, date_to, article_id, limit),
|
| 306 |
+
"hate_speech_count": lambda: _hate_speech_count(date_from, date_to, category, article_id, limit),
|
| 307 |
+
"dialect_breakdown": lambda: _dialect_breakdown(date_from, date_to, dialect, article_id, limit),
|
| 308 |
+
"article_lookup": lambda: _article_lookup(date_from, date_to, article_id, keyword, limit),
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
handler = handlers.get(metric)
|
| 312 |
+
if handler is None:
|
| 313 |
+
return json.dumps({"rows": [], "sql_used": "", "source_refs": [],
|
| 314 |
+
"error": f"Unknown metric: {metric}"})
|
| 315 |
+
|
| 316 |
+
sql_used, rows, refs = await handler()
|
| 317 |
+
source_refs = [{"type": "article", "id": r["article_id"]} for r in refs]
|
| 318 |
+
|
| 319 |
+
return json.dumps(
|
| 320 |
+
{"rows": rows, "sql_used": sql_used.strip(), "source_refs": source_refs},
|
| 321 |
+
default=str,
|
| 322 |
+
)
|