Skip to content

Commit 6c52d08

Browse files
authored
Merge pull request #88 from zestones/18-m32-endpoint-upload-pdf-extraction-opus-vision
18 m32 endpoint upload pdf extraction opus vision
2 parents 3ce142a + 2d97c3e commit 6c52d08

12 files changed

Lines changed: 706 additions & 33 deletions

File tree

.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
# ============================================
22
# ARIA — Environment template
33
# Copy to .env and fill in real values.
4+
# Variables are mandatory unless marked [optional].
45
# ============================================
56

67
# --- TimescaleDB ---
78
POSTGRES_USER=aria
89
POSTGRES_PASSWORD=aria_dev_password
910
POSTGRES_DB=aria
1011
POSTGRES_PORT=5432
12+
# [optional] DB host. Defaults to "timescaledb" (the Docker service name).
13+
# Override to "localhost" when running the backend outside Docker.
14+
# POSTGRES_HOST=timescaledb
1115

1216
# --- Backend (FastAPI) ---
1317
BACKEND_PORT=8000
@@ -22,7 +26,10 @@ VITE_API_BASE_URL=http://localhost:8000
2226

2327
# --- Anthropic / Agents ---
2428
ANTHROPIC_API_KEY=sk-ant-replace-me
25-
ANTHROPIC_MODEL=claude-opus-4-7
29+
# [optional] Model tier used by agents. Controls sonnet vs opus routing in
30+
# model_for() (backend/agents/anthropic_client.py).
31+
# Allowed values: sonnet (default, cheaper) | opus (demo day).
32+
# ARIA_MODEL=sonnet
2633

2734
# --- Simulator ---
2835
# demo: compress 72h scenario into ~4 minutes for live demo

Makefile

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ install.frontend: ## Install frontend npm deps locally (so VS Code can resolve t
5555
# ============================================================
5656
# Dev — Docker stack with hot reload
5757
# ============================================================
58-
.PHONY: up up.backend up.frontend down restart logs ps build rebuild
58+
.PHONY: up up.backend up.frontend down restart logs ps build rebuild doctor doctor.backend doctor.frontend
5959

6060
up: ## Start all services (db, migrate, simulator, backend, frontend) — hot reload enabled
6161
$(COMPOSE) up -d --build
@@ -87,6 +87,55 @@ build: ## Build all docker images
8787
rebuild: ## Force rebuild without cache
8888
$(COMPOSE) build --no-cache
8989

90+
# ---- Doctor — detect dependency drift between manifests and running containers ----
91+
doctor: doctor.backend doctor.frontend ## Check both containers for dependency drift
92+
93+
doctor.backend: ## Compare backend/requirements.txt against pip freeze inside aria-backend
94+
@printf "$(C_CYAN)→ Backend drift check (requirements.txt vs aria-backend)$(C_RESET)\n"
95+
@if ! docker ps --format '{{.Names}}' | grep -qx aria-backend; then \
96+
printf " $(C_YELLOW)! aria-backend not running — start with: make up$(C_RESET)\n"; exit 1; \
97+
fi
98+
@docker exec aria-backend pip freeze 2>/dev/null > /tmp/aria_doctor_pip.txt
99+
@drift=0; missing=0; \
100+
while IFS= read -r line; do \
101+
case "$$line" in ''|\#*) continue;; esac; \
102+
pkg=$$(echo "$$line" | sed -E 's/[[:space:]]*#.*$$//' | tr -d '[:space:]'); \
103+
[ -z "$$pkg" ] && continue; \
104+
name=$$(echo "$$pkg" | sed -E 's/([A-Za-z0-9._-]+).*/\1/'); \
105+
want=$$(echo "$$pkg" | sed -nE 's/^[A-Za-z0-9._-]+==(.+)$$/\1/p'); \
106+
got=$$(grep -iE "^$$name==" /tmp/aria_doctor_pip.txt 2>/dev/null | head -1 | sed -E 's/^[^=]+==//' || true); \
107+
if [ -z "$$got" ]; then \
108+
printf " $(C_RED)✗ MISSING$(C_RESET) %-30s (required: %s)\n" "$$name" "$${want:-any}"; \
109+
missing=$$((missing+1)); \
110+
elif [ -n "$$want" ] && [ "$$want" != "$$got" ]; then \
111+
printf " $(C_YELLOW)~ DRIFT$(C_RESET) %-30s want=%s got=%s\n" "$$name" "$$want" "$$got"; \
112+
drift=$$((drift+1)); \
113+
fi; \
114+
done < $(BACKEND_DIR)/requirements.txt; \
115+
rm -f /tmp/aria_doctor_pip.txt; \
116+
if [ $$missing -gt 0 ]; then \
117+
printf " $(C_RED)$$missing missing package(s) — run: make up (rebuilds image)$(C_RESET)\n"; exit 1; \
118+
elif [ $$drift -gt 0 ]; then \
119+
printf " $(C_YELLOW)! $$drift version drift(s) — run: $(COMPOSE) build --no-cache backend && make up$(C_RESET)\n"; exit 1; \
120+
else \
121+
printf " $(C_GREEN)✓ Backend deps in sync$(C_RESET)\n"; \
122+
fi
123+
124+
doctor.frontend: ## Compare frontend/package.json against installed node_modules in aria-frontend
125+
@printf "$(C_CYAN)→ Frontend drift check (package.json vs aria-frontend)$(C_RESET)\n"
126+
@if ! docker ps --format '{{.Names}}' | grep -qx aria-frontend; then \
127+
printf " $(C_YELLOW)! aria-frontend not running — start with: make up$(C_RESET)\n"; exit 1; \
128+
fi
129+
@out=$$(docker exec aria-frontend sh -c 'cd /app && npm ls --depth=0 --all 2>&1' || true); \
130+
missing=$$(echo "$$out" | grep -cE 'UNMET|missing:|invalid:' || true); \
131+
if [ "$$missing" -gt 0 ]; then \
132+
echo "$$out" | grep -E 'UNMET|missing:|invalid:' | sed 's/^/ /'; \
133+
printf " $(C_RED)$$missing drift/missing entr(ies) — run: docker compose exec frontend npm install$(C_RESET)\n"; \
134+
exit 1; \
135+
else \
136+
printf " $(C_GREEN)✓ Frontend deps in sync$(C_RESET)\n"; \
137+
fi
138+
90139
# ============================================================
91140
# Database
92141
# ============================================================
@@ -156,7 +205,7 @@ frontend.build: ## Production build (vite)
156205
# ============================================================
157206
# Combined targets
158207
# ============================================================
159-
.PHONY: format lint typecheck check test e2e clean backend.smoke.mcp
208+
.PHONY: format lint typecheck check test e2e clean backend.smoke.mcp backend.smoke.kb_upload
160209

161210
format: backend.format frontend.format ## Auto-format both backend and frontend
162211

@@ -178,6 +227,9 @@ backend.smoke.mcp: ## Run MCP server E2E smoke (requires stack + canonical KB; s
178227
backend.smoke.tools: ## Run per-tool MCPClient isolation smoke on P-02 (issue #15; requires stack + canonical KB)
179228
cd $(BACKEND_DIR) && PYTHONPATH=. $(VENV_BIN)/python tests/integration/aria_mcp/tools_p02_isolation.py
180229

230+
backend.smoke.kb_upload: ## M3.2: PDF upload + Opus vision smoke (requires stack + ANTHROPIC_API_KEY; skips if key missing)
231+
cd $(BACKEND_DIR) && PYTHONPATH=. $(VENV_BIN)/python tests/e2e/kb_upload_smoke.py
232+
181233
clean: ## Remove caches and build artifacts
182234
@find . -type d \( -name __pycache__ -o -name .pytest_cache -o -name .mypy_cache -o -name .ruff_cache \) -prune -exec rm -rf {} +
183235
@rm -rf $(BACKEND_DIR)/coverage.xml $(BACKEND_DIR)/htmlcov $(FRONTEND_DIR)/dist

backend/agents/anthropic_client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@
3232
_settings = get_settings()
3333

3434
# Singleton — safe to share across coroutines (httpx.AsyncClient under the hood).
35+
# When ANTHROPIC_API_KEY is unset we still build the client with a placeholder
36+
# so the backend boots; any call site will fail-fast at request time with a
37+
# 401 from the Anthropic API. Agent helpers should guard on the setting.
3538
anthropic = AsyncAnthropic(
36-
api_key=_settings.anthropic_api_key,
39+
api_key=_settings.anthropic_api_key or "sk-ant-placeholder-not-configured",
3740
timeout=60.0,
3841
max_retries=2,
3942
)

backend/agents/kb_builder.py

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""KB Builder agent (M3.2 — issue #18).
2+
3+
Two public helpers used by ``modules/kb/router.py::upload_pdf``:
4+
5+
- ``extract_from_pdf(pdf_bytes, cell_id)`` — Opus-vision extraction with one
6+
retry on parse / validation failure. Returns ``(EquipmentKB, raw_text)``.
7+
- ``bootstrap_thresholds(cell_id, extracted)`` — pre-fills any
8+
``process_signal_definition.kb_threshold_key`` entry that Opus missed with a
9+
``{alert: None, source: "pending_calibration", confidence: 0.0}`` stub. This
10+
keeps ``KbRepository._assert_thresholds_cover_signal_keys`` happy without
11+
blocking the operator on perfect extractions (demo-breaker fix — see
12+
issue #18 §4).
13+
14+
The router calls them in order:
15+
16+
kb, raw = await extract_from_pdf(bytes, cell_id)
17+
kb_dict = await bootstrap_thresholds(cell_id, kb.model_dump(exclude={"kb_meta"}))
18+
await mcp_client.call_tool("update_equipment_kb", {... raw_markdown=raw ...})
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import base64
24+
import json
25+
import logging
26+
from io import BytesIO
27+
from typing import Any, cast
28+
29+
from agents.anthropic_client import anthropic, model_for, parse_json_response
30+
from anthropic.types import MessageParam, TextBlock
31+
from core.database import db
32+
from modules.kb.kb_schema import EquipmentKB
33+
from pydantic import ValidationError
34+
from pypdf import PdfReader
35+
36+
log = logging.getLogger("aria.kb_builder")
37+
38+
39+
_MAX_PAGES = 50
40+
41+
_EXTRACTION_SYSTEM = """You are a maintenance knowledge extraction engine.
42+
Extract structured data from the equipment manual below.
43+
Return ONLY valid JSON — no preamble, no explanation.
44+
45+
Schema:
46+
{
47+
"equipment": {
48+
"equipment_type": str | null,
49+
"manufacturer": str | null,
50+
"model": str | null,
51+
"motor_power_kw": float | null,
52+
"rpm_nominal": int | null,
53+
"service_description": str | null
54+
},
55+
"thresholds": {
56+
"<signal_key>": {
57+
"nominal": float | null,
58+
"alert": float | null,
59+
"trip": float | null,
60+
"low_alert": float | null,
61+
"high_alert": float | null,
62+
"unit": str | null,
63+
"source": "page/section citation",
64+
"confidence": 0.0-1.0
65+
}
66+
},
67+
"failure_patterns": [
68+
{"mode": str, "symptoms": str | null, "mtbf_months": int | null}
69+
],
70+
"maintenance_procedures": [
71+
{"action": str, "interval_months": int | null, "duration_min": int | null, "parts": [str]}
72+
]
73+
}
74+
75+
Leave fields null if not found. Never guess."""
76+
77+
78+
def _first_text(content: list[Any]) -> str:
79+
"""Return the first ``TextBlock.text`` from a Claude response, or ''."""
80+
"""Return the first ``TextBlock.text`` from a Claude response, or ''."""
81+
return next(
82+
(block.text for block in content if isinstance(block, TextBlock)),
83+
"",
84+
)
85+
86+
87+
async def bootstrap_thresholds(cell_id: int, extracted: dict) -> dict:
88+
"""Pre-fill missing ``kb_threshold_key`` entries with null-alert stubs.
89+
90+
Ensures ``KbRepository._assert_thresholds_cover_signal_keys`` passes even
91+
when Opus vision misses a threshold. Stubs have ``alert=None`` so
92+
``core.thresholds.evaluate_threshold`` returns ``breached=False`` —
93+
Sentinel silently skips them until operator calibration fills in real
94+
values. No M4.2 changes required.
95+
96+
Args:
97+
cell_id: Target cell.
98+
extracted: Mutable KB dict (post ``EquipmentKB.model_dump``).
99+
100+
Returns:
101+
The same dict with ``thresholds`` augmented (in-place + returned for
102+
ergonomics).
103+
"""
104+
async with db.pool.acquire() as conn:
105+
rows = await conn.fetch(
106+
"SELECT DISTINCT kb_threshold_key FROM process_signal_definition "
107+
"WHERE cell_id = $1 AND kb_threshold_key IS NOT NULL",
108+
cell_id,
109+
)
110+
required = {r["kb_threshold_key"] for r in rows}
111+
thresholds = dict(extracted.get("thresholds") or {})
112+
added: list[str] = []
113+
for key in required:
114+
if key not in thresholds:
115+
thresholds[key] = {
116+
"alert": None,
117+
"source": "pending_calibration",
118+
"confidence": 0.0,
119+
}
120+
added.append(key)
121+
extracted["thresholds"] = thresholds
122+
if added:
123+
log.info(
124+
"bootstrap_thresholds: cell=%d filled %d missing key(s): %s",
125+
cell_id,
126+
len(added),
127+
sorted(added),
128+
)
129+
return extracted
130+
131+
132+
async def extract_from_pdf(pdf_bytes: bytes, cell_id: int) -> tuple[EquipmentKB, str]:
133+
"""Extract a structured KB from a PDF manual using Opus vision.
134+
135+
Uses Anthropic's ``document`` content block (base64 PDF) so Opus reads the
136+
file natively — no OCR step. Retries once with the validation error fed
137+
back to the model when the first response fails Pydantic validation or
138+
JSON parsing.
139+
140+
Args:
141+
pdf_bytes: Raw PDF bytes (already read from ``UploadFile``).
142+
cell_id: Target cell — used only for log context.
143+
144+
Returns:
145+
Tuple of ``(EquipmentKB, raw_text)``. ``raw_text`` is the first
146+
``TextBlock`` from the (possibly retried) successful response and is
147+
intended for storage in ``equipment_kb.raw_markdown``.
148+
149+
Raises:
150+
ValueError: When the PDF exceeds ``_MAX_PAGES`` (50). Router maps to
151+
HTTP 413.
152+
ValueError | ValidationError: When the second extraction attempt also
153+
fails. Router maps to HTTP 422.
154+
"""
155+
reader = PdfReader(BytesIO(pdf_bytes))
156+
page_count = len(reader.pages)
157+
if page_count > _MAX_PAGES:
158+
raise ValueError(
159+
f"PDF has {page_count} pages; limit is {_MAX_PAGES}. "
160+
"Pre-cut to specs + maintenance + troubleshooting sections."
161+
)
162+
163+
log.info("extract_from_pdf: cell=%d pages=%d bytes=%d", cell_id, page_count, len(pdf_bytes))
164+
165+
b64 = base64.standard_b64encode(pdf_bytes).decode()
166+
# Cast: Anthropic's MessageParam is a strict TypedDict union; the document
167+
# block shape is correct at runtime but pyright cannot narrow the literal
168+
# ``"type": "document"`` through a nested dict literal.
169+
user_msg = cast(
170+
MessageParam,
171+
{
172+
"role": "user",
173+
"content": [
174+
{
175+
"type": "document",
176+
"source": {
177+
"type": "base64",
178+
"media_type": "application/pdf",
179+
"data": b64,
180+
},
181+
},
182+
{
183+
"type": "text",
184+
"text": "Extract the equipment knowledge base from this manual.",
185+
},
186+
],
187+
},
188+
)
189+
190+
response = await anthropic.messages.create(
191+
model=model_for("vision"),
192+
max_tokens=8192,
193+
system=_EXTRACTION_SYSTEM,
194+
messages=[user_msg],
195+
)
196+
log.info(
197+
"extract_from_pdf: tokens input=%d output=%d (cell=%d)",
198+
response.usage.input_tokens,
199+
response.usage.output_tokens,
200+
cell_id,
201+
)
202+
raw_text = _first_text(response.content)
203+
204+
try:
205+
kb = EquipmentKB.model_validate(parse_json_response(response))
206+
return kb, raw_text
207+
except (ValueError, ValidationError, json.JSONDecodeError) as first_err:
208+
first_err_msg = str(first_err)
209+
log.warning(
210+
"extract_from_pdf: first parse failed (cell=%d): %s — retrying",
211+
cell_id,
212+
first_err_msg,
213+
)
214+
215+
retry_messages: list[MessageParam] = [
216+
user_msg,
217+
cast(MessageParam, {"role": "assistant", "content": raw_text}),
218+
cast(
219+
MessageParam,
220+
{
221+
"role": "user",
222+
"content": (
223+
f"Validation failed: {first_err_msg}. Return corrected JSON only \u2014 "
224+
"no preamble, no explanation, no fences."
225+
),
226+
},
227+
),
228+
]
229+
retry = await anthropic.messages.create(
230+
model=model_for("vision"),
231+
max_tokens=8192,
232+
system=_EXTRACTION_SYSTEM,
233+
messages=retry_messages,
234+
)
235+
log.info(
236+
"extract_from_pdf: retry tokens input=%d output=%d (cell=%d)",
237+
retry.usage.input_tokens,
238+
retry.usage.output_tokens,
239+
cell_id,
240+
)
241+
retry_text = _first_text(retry.content)
242+
kb = EquipmentKB.model_validate(parse_json_response(retry))
243+
return kb, retry_text

0 commit comments

Comments
 (0)