Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .gitignore
Binary file not shown.
6 changes: 6 additions & 0 deletions agentic-system/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ dependencies = [
"pydantic>=2.13.4",
"python-dotenv>=1.2.2",
"qdrant-client>=1.18.0",
"fastapi>=0.111.0",
"uvicorn>=0.30.1",
"httpx>=0.27.0",
]

[dependency-groups]
dev = [
"ruff>=0.15.21",
"pytest>=8.2.2",
"pytest-asyncio>=0.23.7",
"pytest-httpx>=0.30.0",
]
44 changes: 44 additions & 0 deletions agentic-system/src/agents/classification_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
from pydantic import BaseModel, Field
from src.core.config import settings

class ClassificationOutput(BaseModel):
classification: str = Field(description="The category of the issue (e.g. Road Damage, Waste Management)")
institution_id: str = Field(description="The UUID of the institution responsible for this issue")

def classification_agent(state: dict) -> dict:
"""
Classifies the issue based on description, image, and GPS coordinates.
"""
llm = ChatOpenAI(model=settings.MODEL_NAME, temperature=0.1)
structured_llm = llm.with_structured_output(ClassificationOutput)

description = state.get("description", "")
image_url = state.get("image_url")
gps = state.get("gps_coords", {})

content = f"Issue Description: {description}\nGPS: {gps.get('lat')}, {gps.get('lng')}\n"

# We could theoretically include image_url for vision models
# if image_url:
# content += f"Image URL: {image_url}\n"

system_message = SystemMessage(
content=(
"You are a classification assistant for the CivicLens platform. "
"Your job is to categorize a reported civic issue and determine the responsible institution UUID. "
"If the issue is related to roads, output Road Damage. If it is garbage, output Waste Management. "
"For now, use 'uuid-road-dept' for road damage and 'uuid-waste-dept' for waste."
)
)

human_message = HumanMessage(content=content)

response = structured_llm.invoke([system_message, human_message])

# Return the classified attributes to be merged into the state
return {
"classification": response.classification,
"institution_id": response.institution_id
}
42 changes: 42 additions & 0 deletions agentic-system/src/api/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import os
from dotenv import load_dotenv

# Load environment variables from .env file before anything else
load_dotenv()

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Dict, Any, Optional
from src.graph.workflow import create_workflow
Comment on lines +1 to +10

app = FastAPI(title="CivicLens ML Agent API")
workflow = create_workflow()

class GPSCoords(BaseModel):
lat: float
lng: float

class ClassifyRequest(BaseModel):
description: str
image_url: Optional[str] = None
gps_coords: GPSCoords

class ClassifyResponse(BaseModel):
classification: str
institution_id: str

def run_classification_workflow(payload: dict) -> dict:
result = workflow.invoke(payload)
return {
"classification": result.get("classification", "Unknown"),
"institution_id": result.get("institution_id", "")
}

@app.post("/classify", response_model=ClassifyResponse)
async def classify_issue(request: ClassifyRequest):
try:
payload = request.model_dump()
result = run_classification_workflow(payload)
return ClassifyResponse(**result)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
Comment on lines +41 to +42
44 changes: 14 additions & 30 deletions agentic-system/src/graph/workflow.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,24 @@
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode, tools_condition
from src.core.state import AgentState
from src.agents.example_agent import example_research_agent
from src.tools.rag_tool import retrieve_civic_information
from typing import TypedDict
from src.agents.classification_agent import classification_agent

class IssueState(TypedDict, total=False):
description: str
image_url: str
gps_coords: dict
classification: str
institution_id: str

def create_workflow():
"""
Builds and compiles the LangGraph workflow.
Builds and compiles the LangGraph classification workflow.
"""
workflow = StateGraph(AgentState)
workflow = StateGraph(IssueState)

# Add nodes
workflow.add_node("agent", example_research_agent)
# Add classification node
workflow.add_node("classify", classification_agent)

# We use the prebuilt ToolNode which will automatically execute the tool if requested
tools = [retrieve_civic_information]
tool_node = ToolNode(tools)
workflow.add_node("tools", tool_node)

# Add edges
workflow.add_edge(START, "agent")

# The tools_condition routing logic checks if the agent decided to call a tool
workflow.add_conditional_edges(
"agent",
tools_condition,
{
# If `tools_condition` returns "tools", route to the tool node
"tools": "tools",
# Otherwise, route to END
END: END,
},
)

# After a tool runs, it should return to the agent to interpret the result
workflow.add_edge("tools", "agent")
workflow.add_edge(START, "classify")
workflow.add_edge("classify", END)

return workflow.compile()
31 changes: 31 additions & 0 deletions agentic-system/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch
from src.api.server import app
Comment on lines +1 to +4

client = TestClient(app)

@patch("src.api.server.run_classification_workflow")
def test_classify_issue_endpoint(mock_run_workflow):
# Mock the return value of the graph workflow
mock_run_workflow.return_value = {
"classification": "Road Damage",
"institution_id": "123e4567-e89b-12d3-a456-426614174000"
}

payload = {
"description": "There is a huge pothole here.",
"image_url": "http://example.com/image.jpg",
"gps_coords": {"lat": 6.9271, "lng": 79.8612}
}

response = client.post("/classify", json=payload)

assert response.status_code == 200
data = response.json()
assert data["classification"] == "Road Damage"
assert data["institution_id"] == "123e4567-e89b-12d3-a456-426614174000"

# Ensure the workflow was called with correct data
mock_run_workflow.assert_called_once_with(payload)

31 changes: 31 additions & 0 deletions agentic-system/tests/test_graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import pytest
from src.graph.workflow import create_workflow
from unittest.mock import patch, MagicMock
from langchain_core.messages import AIMessage
from src.agents.classification_agent import ClassificationOutput
Comment on lines +2 to +5

@pytest.mark.asyncio
@patch("src.agents.classification_agent.ChatOpenAI")
async def test_workflow_classification(mock_chat_openai):
# Mock LLM response to return a structured output (Pydantic model)
mock_llm_instance = MagicMock()
mock_llm_instance.with_structured_output.return_value.invoke.return_value = ClassificationOutput(
classification="Waste Management",
institution_id="waste-dept-uuid"
)
mock_chat_openai.return_value = mock_llm_instance

workflow = create_workflow()

initial_state = {
"description": "Garbage dumped on the side of the road",
"image_url": "http://example.com/garbage.jpg",
"gps_coords": {"lat": 6.9, "lng": 79.9}
}

result = workflow.invoke(initial_state)

assert "classification" in result
assert result["classification"] == "Waste Management"
assert result["institution_id"] == "waste-dept-uuid"

15 changes: 14 additions & 1 deletion backend-api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,23 @@ ENVIRONMENT=development
# Format: postgres://USER:PASSWORD@HOST:PORT/DATABASE?sslmode=disable
DATABASE_URL=postgres://civiclens:password@localhost:5432/civiclens_dev?sslmode=disable

# ─── Auth ─────────────────────────────────────────────────────────────────────
# ─── Auth / media JWT ─────────────────────────────────────────────────────────
# Generate with: openssl rand -hex 32
# Signs session JWTs and one-time media upload/download JWTs.
JWT_SECRET=change-me-in-production-use-a-long-random-secret

# ─── Media storage ────────────────────────────────────────────────────────────
# When true, files are saved under MEDIA_STORAGE_PATH and R2 credentials are not required.
MEDIA_LOCAL_STORAGE=true
MEDIA_STORAGE_PATH=./data/media

# ─── Cloudflare R2 (used when MEDIA_LOCAL_STORAGE=false) ──────────────────────
R2_ENDPOINT=https://43dd3d9a90362fbabada2b369e1bb5c7.r2.cloudflarestorage.com
R2_BUCKET=draftly
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_REGION=auto

# ─── Initial Accounts & Auto-Seeding ──────────────────────────────────────────
INITIAL_ADMIN_EMAIL=admin@civiclens.org
INITIAL_ADMIN_PASSWORD=AdminSecurePass123!
Expand Down
Binary file added backend-api/.gitignore
Binary file not shown.
16 changes: 15 additions & 1 deletion backend-api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# make clean – remove build artifacts
# ────────────────────────────────────────────────────────────────────────────

.PHONY: all run build test test-v lint fmt vet tidy sqlc-gen clean help
.PHONY: all run build test test-v lint fmt vet tidy sqlc-gen migrate-up migrate-down clean help

# Default goal
all: fmt lint test build
Expand Down Expand Up @@ -82,10 +82,22 @@ tidy:

# ── Database / sqlc ───────────────────────────────────────────────────────────

MIGRATIONS_DIR := internal/db/migrations

## sqlc-gen: Regenerate type-safe Go code from SQL schema and queries
sqlc-gen:
sqlc generate -f sqlc/sqlc.yaml

## migrate-up: Apply all pending database migrations
migrate-up:
@test -n "$(DATABASE_URL)" || (echo "DATABASE_URL is required" && exit 1)
migrate -path $(MIGRATIONS_DIR) -database "$(DATABASE_URL)" up

## migrate-down: Roll back the most recent migration
migrate-down:
@test -n "$(DATABASE_URL)" || (echo "DATABASE_URL is required" && exit 1)
migrate -path $(MIGRATIONS_DIR) -database "$(DATABASE_URL)" down 1

# ── Cleanup ───────────────────────────────────────────────────────────────────

## clean: Remove build artifacts and coverage reports
Expand All @@ -110,5 +122,7 @@ help:
@echo make vet - run go vet
@echo make tidy - tidy go module dependencies
@echo make sqlc-gen - regenerate type-safe DB code from SQL files
@echo make migrate-up - apply all pending database migrations
@echo make migrate-down - roll back the most recent migration
@echo make build - produce a production binary
@echo make clean - remove build artifacts
Loading