Skip to content

Commit 9ecb4e1

Browse files
lapc506claude
andauthored
ALT-38: Desplegar agentic-core como sidecar K8s para matching de rescatistas (#9)
* feat(agent-sidecar): create sidecar app with matching graph, gRPC server, and proto definition Implements ALT-38 Phases 1-4: - Phase 1: pyproject.toml, proto definition, gen-proto script, directory structure - Phase 2: LangGraph matching pipeline (fetch, enrich, score, rank nodes) - Phase 3: rescuer-matching persona YAML - Phase 4: gRPC server, interceptors, handlers, settings, Dockerfile Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(matching): add NestJS MatchingModule with gRPC client and K8s sidecar manifests * fix(agent-sidecar): align SQL queries with actual AltruPets schema - Use START constant instead of deprecated set_entry_point() - Fix fetch_candidates SQL: roles is array column on users (not join table), rescue_alerts uses auxiliarId/rescuerId (not rescue_assignments) - Use COALESCE for name fallback, correct column casing (isActive, firstName) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 89e6618 commit 9ecb4e1

40 files changed

+1635
-37
lines changed

Makefile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ TIMEOUT ?= 900000
3030
dev-security-container dev-security-iac dev-security-fix \
3131
dev-mobile-launch dev-mobile-desktop dev-mobile-launch-desktop dev-mobile-launch-emulator dev-mobile-launch-device \
3232
dev-mobile-widgetbook dev-mobile-analyze dev-mobile-test dev-mobile-test-coverage dev-mobile-lint \
33+
dev-agent-sidecar-build dev-agent-sidecar-deploy \
3334
dev-admin-server-install dev-admin-server-start dev-admin-server-stop dev-admin-server-restart \
3435
dev-admin-server-status dev-admin-server-logs dev-admin-server-test \
3536
qa-terraform-deploy qa-terraform-destroy qa-verify \
@@ -753,3 +754,19 @@ dev-mobile-desktop: ## Launch Flutter desktop app (Windows native / Linux fallba
753754
else \
754755
cd apps/mobile && ./launch_flutter_debug.sh -l; \
755756
fi
757+
758+
# ==========================================
759+
# DEV - Agent Sidecar
760+
# ==========================================
761+
762+
dev-agent-sidecar-build: ## Build agent-sidecar image
763+
@echo "$(BLUE)Building agent-sidecar image...$(NC)"
764+
@podman build -t altrupets-agent-sidecar:dev apps/agent-sidecar/
765+
@minikube image load altrupets-agent-sidecar:dev
766+
@echo "$(GREEN)Agent sidecar image built and loaded$(NC)"
767+
768+
dev-agent-sidecar-deploy: ## Deploy agent-sidecar (rebuild + apply)
769+
@$(MAKE) dev-agent-sidecar-build
770+
@kubectl apply -f k8s/base/backend/configmap-sidecar.yaml
771+
@kubectl rollout restart deployment/backend -n altrupets-dev
772+
@echo "$(GREEN)Agent sidecar deployed$(NC)"

apps/agent-sidecar/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM python:3.12-slim AS builder
2+
WORKDIR /app
3+
COPY pyproject.toml .
4+
RUN pip install --no-cache-dir .
5+
6+
FROM python:3.12-slim
7+
WORKDIR /app
8+
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
9+
COPY --from=builder /usr/local/bin /usr/local/bin
10+
COPY . .
11+
EXPOSE 50051 9090 8080
12+
USER nobody
13+
CMD ["python", "main.py"]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
name: rescuer-matching
2+
description: Encuentra los mejores rescatistas disponibles para una alerta de rescate
3+
model: claude-sonnet-4-6
4+
graph_template: custom
5+
capabilities: []
6+
tools: []
7+
slo:
8+
latency_p99_ms: 5000
9+
success_rate: 0.99
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from config.settings import Settings, get_settings
2+
3+
__all__ = ["Settings", "get_settings"]
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Application settings loaded from environment variables with SIDECAR_ prefix."""
2+
3+
from __future__ import annotations
4+
5+
from functools import lru_cache
6+
7+
from pydantic import Field
8+
from pydantic_settings import BaseSettings
9+
10+
11+
class Settings(BaseSettings):
12+
"""Configuration for the agent-sidecar service."""
13+
14+
model_config = {"env_prefix": "SIDECAR_"}
15+
16+
# Database
17+
database_url: str = Field(
18+
default="postgresql://localhost:5432/altrupets",
19+
description="PostgreSQL connection URL",
20+
)
21+
22+
# FalkorDB
23+
falkordb_host: str = Field(default="localhost", description="FalkorDB hostname")
24+
falkordb_port: int = Field(default=6379, description="FalkorDB port")
25+
26+
# gRPC
27+
grpc_port: int = Field(default=50051, description="gRPC server listen port")
28+
29+
# Observability
30+
metrics_port: int = Field(default=9090, description="Prometheus metrics port")
31+
health_port: int = Field(default=8080, description="Health check HTTP port")
32+
33+
# Matching
34+
max_candidates: int = Field(default=5, description="Max candidates to return")
35+
36+
# Radius overrides per urgency (km)
37+
radius_low: float = Field(default=15.0, description="Search radius for LOW urgency")
38+
radius_medium: float = Field(default=25.0, description="Search radius for MEDIUM urgency")
39+
radius_high: float = Field(default=50.0, description="Search radius for HIGH urgency")
40+
radius_critical: float = Field(default=100.0, description="Search radius for CRITICAL urgency")
41+
42+
43+
@lru_cache(maxsize=1)
44+
def get_settings() -> Settings:
45+
"""Return a cached singleton of the application settings."""
46+
return Settings()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from graphs.rescuer_matching_graph import matching_graph
2+
3+
__all__ = ["matching_graph"]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from graphs.nodes.fetch_candidates import fetch_candidates
2+
from graphs.nodes.enrich_from_graph import enrich_from_graph
3+
from graphs.nodes.score_candidates import score_candidates
4+
from graphs.nodes.rank_and_explain import rank_and_explain
5+
6+
__all__ = [
7+
"fetch_candidates",
8+
"enrich_from_graph",
9+
"score_candidates",
10+
"rank_and_explain",
11+
]
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Enrich candidates with rescue history and social graph data from FalkorDB."""
2+
3+
from __future__ import annotations
4+
5+
import structlog
6+
7+
from config.settings import get_settings
8+
from graphs.state import MatchingState
9+
10+
logger = structlog.get_logger(__name__)
11+
12+
13+
async def _query_falkordb(graph, user_id: str) -> dict:
14+
"""Run Cypher queries against FalkorDB to fetch enrichment data for one candidate."""
15+
16+
# Rescue count
17+
result = graph.query(
18+
"MATCH (r:Rescuer {id: $id})-[:RESCUED]->(a:Animal) RETURN count(a) AS cnt",
19+
params={"id": user_id},
20+
)
21+
rescue_count = result.result_set[0][0] if result.result_set else 0
22+
23+
# Species specializations
24+
result = graph.query(
25+
"MATCH (r:Rescuer {id: $id})-[:RESCUED]->(a:Animal) "
26+
"RETURN DISTINCT a.species AS species",
27+
params={"id": user_id},
28+
)
29+
specializations = [row[0] for row in result.result_set] if result.result_set else []
30+
31+
# Vet network size
32+
result = graph.query(
33+
"MATCH (r:Rescuer {id: $id})-[:KNOWS]->(v:Vet) RETURN count(v) AS cnt",
34+
params={"id": user_id},
35+
)
36+
vet_network_size = result.result_set[0][0] if result.result_set else 0
37+
38+
# Endorsements
39+
result = graph.query(
40+
"MATCH (r:Rescuer {id: $id})<-[:ENDORSED]-(u) RETURN count(u) AS cnt",
41+
params={"id": user_id},
42+
)
43+
endorsement_count = result.result_set[0][0] if result.result_set else 0
44+
45+
return {
46+
"rescue_count": rescue_count,
47+
"species_specializations": specializations,
48+
"vet_network_size": vet_network_size,
49+
"endorsement_count": endorsement_count,
50+
}
51+
52+
53+
async def enrich_from_graph(state: MatchingState) -> MatchingState:
54+
"""Enrich each candidate with data from FalkorDB.
55+
56+
If FalkorDB is unavailable, candidates keep their defaults (0 counts, empty lists).
57+
"""
58+
settings = get_settings()
59+
candidates = state["candidates"]
60+
61+
if not candidates:
62+
return state
63+
64+
try:
65+
from falkordb import FalkorDB
66+
67+
db = FalkorDB(host=settings.falkordb_host, port=settings.falkordb_port)
68+
graph = db.select_graph("altrupets")
69+
except Exception:
70+
logger.warning("falkordb_unavailable", msg="Using default enrichment values")
71+
return state
72+
73+
enriched = []
74+
for candidate in candidates:
75+
try:
76+
data = await _query_falkordb(graph, candidate.user_id)
77+
enriched.append(
78+
candidate.model_copy(update=data)
79+
)
80+
except Exception:
81+
logger.warning(
82+
"enrichment_failed_for_candidate",
83+
user_id=candidate.user_id,
84+
)
85+
enriched.append(candidate)
86+
87+
logger.info("enrichment_complete", enriched_count=len(enriched))
88+
return {**state, "candidates": enriched}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Fetch rescuer candidates from PostgreSQL within a radius determined by urgency."""
2+
3+
from __future__ import annotations
4+
5+
import math
6+
7+
import asyncpg
8+
import structlog
9+
10+
from config.settings import get_settings
11+
from graphs.state import CandidateData, MatchingState
12+
13+
logger = structlog.get_logger(__name__)
14+
15+
# Radius in km per urgency level
16+
URGENCY_RADIUS: dict[str, float] = {
17+
"LOW": 15.0,
18+
"MEDIUM": 25.0,
19+
"HIGH": 50.0,
20+
"CRITICAL": 100.0,
21+
}
22+
23+
# Earth radius in km for Haversine
24+
_EARTH_RADIUS_KM = 6371.0
25+
26+
27+
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
28+
"""Return distance in km between two lat/lng points using the Haversine formula."""
29+
rlat1, rlon1, rlat2, rlon2 = (
30+
math.radians(lat1),
31+
math.radians(lon1),
32+
math.radians(lat2),
33+
math.radians(lon2),
34+
)
35+
dlat = rlat2 - rlat1
36+
dlon = rlon2 - rlon1
37+
a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
38+
return 2 * _EARTH_RADIUS_KM * math.asin(math.sqrt(a))
39+
40+
41+
async def fetch_candidates(state: MatchingState) -> MatchingState:
42+
"""Query PostgreSQL for users with RESCUER or HELPER role within the urgency-based radius.
43+
44+
Filters:
45+
- Active users only
46+
- Role is RESCUER or HELPER
47+
- Not currently assigned to an active (non-completed) rescue
48+
- Within Haversine distance of the alert coordinates
49+
"""
50+
settings = get_settings()
51+
animal = state["animal_info"]
52+
max_radius = URGENCY_RADIUS.get(animal.urgency, 25.0)
53+
54+
logger.info(
55+
"fetching_candidates",
56+
rescue_alert_id=animal.rescue_alert_id,
57+
urgency=animal.urgency,
58+
max_radius_km=max_radius,
59+
)
60+
61+
try:
62+
conn: asyncpg.Connection = await asyncpg.connect(settings.database_url)
63+
except Exception:
64+
logger.exception("database_connection_failed")
65+
return {**state, "candidates": [], "total_evaluated": 0, "error": "database_connection_failed"}
66+
67+
try:
68+
# Fetch active users with RESCUER or HELPER roles who are not on an active rescue.
69+
# AltruPets schema: roles is a postgres array column on users table,
70+
# rescue_alerts tracks active rescues (auxiliarId/rescuerId columns).
71+
query = """
72+
SELECT
73+
u.id,
74+
COALESCE(u."firstName", u.username) AS name,
75+
u.latitude,
76+
u.longitude,
77+
u.roles
78+
FROM users u
79+
WHERE u."isActive" = true
80+
AND (u.roles && ARRAY['RESCUER', 'HELPER']::varchar[])
81+
AND u.latitude IS NOT NULL
82+
AND u.longitude IS NOT NULL
83+
AND u.id NOT IN (
84+
SELECT COALESCE(ra."auxiliarId", ra."rescuerId")
85+
FROM rescue_alerts ra
86+
WHERE ra.status NOT IN ('COMPLETED', 'CANCELLED', 'REJECTED', 'EXPIRED')
87+
AND (ra."auxiliarId" IS NOT NULL OR ra."rescuerId" IS NOT NULL)
88+
)
89+
"""
90+
rows = await conn.fetch(query)
91+
92+
candidates: list[CandidateData] = []
93+
for row in rows:
94+
dist = _haversine(animal.latitude, animal.longitude, float(row["latitude"]), float(row["longitude"]))
95+
if dist <= max_radius:
96+
candidates.append(
97+
CandidateData(
98+
user_id=str(row["id"]),
99+
name=row["name"] or "Unknown",
100+
distance_km=round(dist, 2),
101+
available_capacity=0, # enriched later from casa_cuna data
102+
roles=list(row["roles"]) if row["roles"] else [],
103+
)
104+
)
105+
106+
logger.info("candidates_fetched", count=len(candidates), total_rows=len(rows))
107+
return {**state, "candidates": candidates, "total_evaluated": len(candidates)}
108+
109+
except Exception:
110+
logger.exception("fetch_candidates_query_failed")
111+
return {**state, "candidates": [], "total_evaluated": 0, "error": "fetch_candidates_query_failed"}
112+
finally:
113+
await conn.close()
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Rank scored candidates and generate human-readable explanations."""
2+
3+
from __future__ import annotations
4+
5+
import structlog
6+
7+
from config.settings import get_settings
8+
from graphs.state import MatchingState
9+
10+
logger = structlog.get_logger(__name__)
11+
12+
13+
async def rank_and_explain(state: MatchingState) -> MatchingState:
14+
"""Sort candidates by score descending, take top N, and generate explanations."""
15+
settings = get_settings()
16+
max_candidates = settings.max_candidates
17+
18+
sorted_candidates = sorted(state["candidates"], key=lambda c: c.score, reverse=True)
19+
top = sorted_candidates[:max_candidates]
20+
21+
explained = []
22+
for candidate in top:
23+
explanation = (
24+
f"Distance: {candidate.distance_km}km | "
25+
f"Capacity: {candidate.available_capacity} | "
26+
f"Rescues: {candidate.rescue_count} | "
27+
f"Score: {candidate.score:.2f}"
28+
)
29+
explained.append(candidate.model_copy(update={"explanation": explanation}))
30+
31+
logger.info(
32+
"ranking_complete",
33+
total_candidates=len(state["candidates"]),
34+
top_returned=len(explained),
35+
)
36+
return {**state, "top_candidates": explained}

0 commit comments

Comments
 (0)