Skip to content
Merged
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
2 changes: 1 addition & 1 deletion backend/aria_mcp/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@

from __future__ import annotations

from aria_mcp.tools import kpi, signals # noqa: F401 side-effect imports
from aria_mcp.tools import context, hierarchy, kpi, signals # noqa: F401 side-effect imports
135 changes: 135 additions & 0 deletions backend/aria_mcp/tools/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Human-context tools (M2.4) — logbook, shift assignments, work orders.

Thin wrappers over existing repositories so the Investigator (M4.3) and Q&A
(M5.x) agents can pull operator notes, shift coverage, and intervention
history alongside the signal/KPI tools.
"""

from __future__ import annotations

from datetime import date

from aria_mcp._common import with_conn
from aria_mcp.server import mcp
from core.datetime_helpers import parse_tz_aware
from modules.logbook.repository import LogbookRepository
from modules.logbook.schemas import LogbookEntryOut
from modules.shift.repository import ShiftRepository
from modules.shift.schemas import ShiftAssignmentOut
from modules.work_order.repository import WorkOrderRepository
from modules.work_order.schemas import WorkOrderOut

# Default cap on rows for list-style tools — keeps token budget bounded
# when an over-eager agent forgets to narrow the window.
_DEFAULT_LIMIT = 200


def _parse_iso_date(s: str) -> date:
"""Parse a ``YYYY-MM-DD`` date string. Raises ``ValueError`` on bad input."""
return date.fromisoformat(s.strip())


@mcp.tool()
async def get_logbook_entries(
cell_id: int,
window_start: str,
window_end: str,
category: str | None = None,
severity: str | None = None,
limit: int = _DEFAULT_LIMIT,
) -> list[dict]:
"""Operator logbook entries for a cell within a time window.

Args:
cell_id: Target cell.
window_start: ISO-8601 with TZ offset (inclusive).
window_end: ISO-8601 with TZ offset (exclusive).
category: Optional filter — ``observation``, ``maintenance``, ``incident``,
``changeover``, ``note``.
severity: Optional filter — ``info``, ``warning``, ``critical``.
limit: Max rows returned (default 200).

Returns:
List of ``LogbookEntryOut`` dicts ordered by ``entry_time DESC``.
"""
ws = parse_tz_aware(window_start)
we = parse_tz_aware(window_end)
async with with_conn() as conn:
rows = await LogbookRepository(conn).list(
cell_id=cell_id,
category=category,
severity=severity,
window_start=ws,
window_end=we,
limit=limit,
)
return [LogbookEntryOut.model_validate(dict(r)).model_dump(mode="json") for r in rows]


@mcp.tool()
async def get_shift_assignments(
cell_id: int,
date_start: str,
date_end: str,
) -> list[dict]:
"""Shift assignments covering a cell over a date range.

Args:
cell_id: Target cell.
date_start: ``YYYY-MM-DD`` (inclusive).
date_end: ``YYYY-MM-DD`` (inclusive — assignments are day-granular).

Returns:
List of ``ShiftAssignmentOut`` dicts ordered by ``assigned_date DESC``.
"""
ds = _parse_iso_date(date_start)
de = _parse_iso_date(date_end)
async with with_conn() as conn:
rows = await ShiftRepository(conn).list_assignments_for_range(
date_start=ds, date_end=de, cell_id=cell_id
)
return [ShiftAssignmentOut.model_validate(dict(r)).model_dump(mode="json") for r in rows]


@mcp.tool()
async def get_work_orders(
cell_id: int | None = None,
status: str | None = None,
date_start: str | None = None,
date_end: str | None = None,
priority: str | None = None,
generated_by_agent: bool | None = None,
limit: int = _DEFAULT_LIMIT,
) -> list[dict]:
"""Work orders matching the given filters.

All filters are optional — combine ``status='open'`` with ``priority='critical'``
to surface "what's burning right now", or ``generated_by_agent=True`` to inspect
agent output history.

Args:
cell_id: Restrict to one cell (omit for all cells).
status: ``detected``, ``analyzed``, ``open``, ``in_progress``, ``completed``,
``cancelled``.
date_start: ISO-8601 with TZ — filter on ``created_at`` (inclusive).
date_end: ISO-8601 with TZ — filter on ``created_at`` (exclusive).
priority: ``low``, ``medium``, ``high``, ``critical``.
generated_by_agent: True → only agent-generated WOs, False → only manual.
limit: Max rows returned (default 200).

Returns:
List of ``WorkOrderOut`` dicts ordered by ``created_at DESC``.
"""
ws = parse_tz_aware(date_start) if date_start is not None else None
we = parse_tz_aware(date_end) if date_end is not None else None
async with with_conn() as conn:
rows = await WorkOrderRepository(conn).list(
cell_id=cell_id,
status=status,
limit=limit,
date_start=ws,
date_end=we,
priority=priority,
generated_by_agent=generated_by_agent,
)
return [WorkOrderOut.model_validate(dict(r)).model_dump(mode="json") for r in rows]
46 changes: 46 additions & 0 deletions backend/aria_mcp/tools/hierarchy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Hierarchy tools (M2.4 audit add-on) — `list_cells` for name → id resolution.

The Q&A agent (M5.x) receives natural-language queries like "what's the OEE of
P-02?" and needs to resolve "P-02" → ``cell_id`` before calling KPI tools.
Without this tool, the LLM either invents an id or we inject the full cell
list into the system prompt — both break for multi-cell sites.
"""

from __future__ import annotations

from aria_mcp._common import with_conn
from aria_mcp.server import mcp
from modules.hierarchy.repository import HierarchyRepository
from modules.hierarchy.schemas import CellOut


@mcp.tool()
async def list_cells(site_id: int | None = None) -> list[dict]:
"""Enumerate cells, optionally restricted to one site.

Args:
site_id: Optional site filter. When set, only cells whose parent line
belongs to an area under this site are returned. Omit to list all
cells across the enterprise.

Returns:
List of ``CellOut`` dicts (id, name, parentid, ideal_cycle_time_seconds, ...)
ordered by ``id``. Disabled cells are included — agents should filter on
``disable`` if they need only operational cells.
"""
async with with_conn() as conn:
repo = HierarchyRepository(conn)
if site_id is None:
rows = await repo.list_cells()
else:
rows = await conn.fetch(
"""
SELECT c.* FROM cell c
JOIN line l ON c.parentid = l.id
JOIN area a ON l.parentid = a.id
WHERE a.parentid = $1
ORDER BY c.id
""",
site_id,
)
return [CellOut.model_validate(dict(r)).model_dump(mode="json") for r in rows]
88 changes: 88 additions & 0 deletions backend/infrastructure/database/seeds/p02_human_context.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
-- ============================================
-- ARIA — P-02 human-context seed (issue #11)
--
-- Seeds shift_assignment and work_order rows so the M2.4 MCP tools
-- (`get_shift_assignments`, `get_work_orders`) return non-empty arrays
-- against a fresh stack — required by the issue acceptance criteria.
--
-- Idempotent: every INSERT either targets a unique key (assignments) or is
-- guarded by a NOT EXISTS lookup (work_order has no natural unique key).
-- ============================================
-- ---- shift_assignment: morning + afternoon coverage on P-02 today + yesterday
INSERT INTO shift_assignment(shift_id, user_id, cell_id, assigned_date)
SELECT
s.id,
u.id,
c.id,
d::date
FROM
cell c
CROSS JOIN (
VALUES (CURRENT_DATE),
(CURRENT_DATE - INTERVAL '1 day')) AS days(d)
JOIN shift s ON s.name IN ('Morning', 'Afternoon')
JOIN users u ON (s.name = 'Morning'
AND u.username = 'operator')
OR (s.name = 'Afternoon'
AND u.username = 'viewer')
WHERE
c.name = 'P-02'
ON CONFLICT (shift_id,
user_id,
cell_id,
assigned_date)
DO NOTHING;

-- ---- work_order: one open critical agent-generated WO + one completed manual WO
INSERT INTO work_order(cell_id, title, description, priority, status, estimated_duration_min, created_by, generated_by_agent, trigger_anomaly_time, rca_summary, recommended_actions, created_at)
SELECT
c.id,
'Bearing replacement — vibration trending up',
'Discharge bearing vibration at 4.8 mm/s (alert threshold 4.5). Predicted failure in 5–10 days based on trend.',
'critical',
'open',
240,
'work_order_agent',
TRUE,
NOW() - INTERVAL '2 hours',
'Trend analysis over 24h shows monotonic vibration drift on discharge bearing. Bearing temp also drifting up (+8 °C from baseline). Pattern matches failure_history #1 (bearing_wear, MTBF ≈ 12 months — current bearing installed 14 months ago).',
'[{"action": "Replace upper + lower bearings", "parts": ["Grundfos 96416067", "Grundfos 96416068"], "duration_min": 240}, {"action": "Realign coupling post-replacement", "duration_min": 30}]'::jsonb,
NOW() - INTERVAL '90 minutes'
FROM
cell c
WHERE
c.name = 'P-02'
AND NOT EXISTS (
SELECT
1
FROM
work_order
WHERE
cell_id = c.id
AND title = 'Bearing replacement — vibration trending up');

INSERT INTO work_order(cell_id, title, description, priority, status, estimated_duration_min, created_by, generated_by_agent, created_at, completed_at)
SELECT
c.id,
'Monthly preventive greasing',
'Standard PM lubrication routine — discharge + suction bearings.',
'medium',
'completed',
30,
'operator',
FALSE,
NOW() - INTERVAL '15 days',
NOW() - INTERVAL '15 days' + INTERVAL '25 minutes'
FROM
cell c
WHERE
c.name = 'P-02'
AND NOT EXISTS (
SELECT
1
FROM
work_order
WHERE
cell_id = c.id
AND title = 'Monthly preventive greasing');

25 changes: 25 additions & 0 deletions backend/modules/shift/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,31 @@ async def list_assignments(
sql = f"{self._ASSIGN_SELECT} {where} ORDER BY sa.assigned_date DESC, sa.id"
return await self.conn.fetch(sql, *params)

async def list_assignments_for_range(
self,
date_start: date,
date_end: date,
cell_id: int | None = None,
user_id: int | None = None,
):
"""Range variant used by MCP `get_shift_assignments` (audit §1, issue #11).

``date_end`` is inclusive — shift assignments are day-granular.
"""
params: list[object] = [date_start, date_end]
clauses = ["sa.assigned_date >= $1", "sa.assigned_date <= $2"]
if cell_id is not None:
params.append(cell_id)
clauses.append(f"sa.cell_id = ${len(params)}")
if user_id is not None:
params.append(user_id)
clauses.append(f"sa.user_id = ${len(params)}")
sql = (
f"{self._ASSIGN_SELECT} WHERE {' AND '.join(clauses)} "
"ORDER BY sa.assigned_date DESC, sa.id"
)
return await self.conn.fetch(sql, *params)

async def list_assignments_for_shift_date(self, shift_id: int, day: date):
return await self.conn.fetch(
self._ASSIGN_SELECT + " WHERE sa.shift_id = $1 AND sa.assigned_date = $2",
Expand Down
23 changes: 22 additions & 1 deletion backend/modules/work_order/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,35 @@ def __init__(self, conn: asyncpg.Connection) -> None:
LEFT JOIN users u ON wo.assigned_to = u.id
"""

async def list(self, cell_id: int | None, status: str | None, limit: int):
async def list(
self,
cell_id: int | None,
status: str | None,
limit: int,
date_start: Any | None = None,
date_end: Any | None = None,
priority: str | None = None,
generated_by_agent: bool | None = None,
):
clauses, params = [], []
if cell_id is not None:
params.append(cell_id)
clauses.append(f"wo.cell_id = ${len(params)}")
if status is not None:
params.append(status)
clauses.append(f"wo.status = ${len(params)}")
if priority is not None:
params.append(priority)
clauses.append(f"wo.priority = ${len(params)}")
if generated_by_agent is not None:
params.append(generated_by_agent)
clauses.append(f"wo.generated_by_agent = ${len(params)}")
if date_start is not None:
params.append(date_start)
clauses.append(f"wo.created_at >= ${len(params)}")
if date_end is not None:
params.append(date_end)
clauses.append(f"wo.created_at < ${len(params)}")
where = "WHERE " + " AND ".join(clauses) if clauses else ""
params.append(limit)
sql = f"{self._SELECT} {where} ORDER BY wo.created_at DESC LIMIT ${len(params)}"
Expand Down
Loading
Loading