ArabicNewsAnalyzer commited on
Commit
de28957
·
verified ·
1 Parent(s): 302f332

Upload 56 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env +29 -0
  2. .env.example +25 -0
  3. .gitignore +44 -0
  4. Dockerfile +28 -0
  5. app/__init__.py +0 -0
  6. app/__pycache__/__init__.cpython-312.pyc +0 -0
  7. app/__pycache__/config.cpython-312.pyc +0 -0
  8. app/__pycache__/main.cpython-312.pyc +0 -0
  9. app/agent/__init__.py +0 -0
  10. app/agent/__pycache__/__init__.cpython-312.pyc +0 -0
  11. app/agent/__pycache__/graph.cpython-312.pyc +0 -0
  12. app/agent/__pycache__/llm.cpython-312.pyc +0 -0
  13. app/agent/__pycache__/prompts.cpython-312.pyc +0 -0
  14. app/agent/__pycache__/state.cpython-312.pyc +0 -0
  15. app/agent/graph.py +117 -0
  16. app/agent/llm.py +30 -0
  17. app/agent/prompts.py +44 -0
  18. app/agent/state.py +16 -0
  19. app/config.py +47 -0
  20. app/db/__init__.py +0 -0
  21. app/db/__pycache__/__init__.cpython-312.pyc +0 -0
  22. app/db/__pycache__/neo4j.cpython-312.pyc +0 -0
  23. app/db/__pycache__/postgres.cpython-312.pyc +0 -0
  24. app/db/neo4j.py +49 -0
  25. app/db/postgres.py +49 -0
  26. app/deps.py +11 -0
  27. app/main.py +51 -0
  28. app/memory/__init__.py +0 -0
  29. app/memory/__pycache__/__init__.cpython-312.pyc +0 -0
  30. app/memory/__pycache__/history_trimmer.cpython-312.pyc +0 -0
  31. app/memory/__pycache__/redis_checkpointer.cpython-312.pyc +0 -0
  32. app/memory/history_trimmer.py +78 -0
  33. app/memory/redis_checkpointer.py +38 -0
  34. app/schemas/__init__.py +0 -0
  35. app/schemas/__pycache__/__init__.cpython-312.pyc +0 -0
  36. app/schemas/__pycache__/request.cpython-312.pyc +0 -0
  37. app/schemas/__pycache__/response.cpython-312.pyc +0 -0
  38. app/schemas/request.py +5 -0
  39. app/schemas/response.py +14 -0
  40. app/services/__pycache__/nlp_client.cpython-312.pyc +0 -0
  41. app/services/nlp_client.py +80 -0
  42. app/tools/__init__.py +0 -0
  43. app/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  44. app/tools/__pycache__/article_tool.cpython-312.pyc +0 -0
  45. app/tools/__pycache__/graph_tool.cpython-312.pyc +0 -0
  46. app/tools/__pycache__/sql_tool.cpython-312.pyc +0 -0
  47. app/tools/article_tool.py +49 -0
  48. app/tools/graph_tool.py +177 -0
  49. app/tools/hybrid_tool.py +131 -0
  50. 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
+ )