pryscraper/pry_review.py
cryptorugmunch 0ecc250349 refactor(pry): rename 19 root modules to pry_<name> to remove naming collision with routers/
The duplicate file names (advanced, agency, auth, compliance, costing, etc.) in
both root and routers/ made imports ambiguous. Renamed root modules to
pry_<name>.py so:

  from quality import X     ->  from pry_quality import X
  from routers.quality import router  (unchanged)

Also:
- Renamed x402.py to pry_x402/ package directory
- Fixed 21+ bare imports across api.py, deps.py, routers/, tests/, llm_providers/

Tests: 593 passed, 1 skipped (test_ready_returns_200 fails pre-existing
because Ollama is unreachable from test env, unrelated to this refactor).

Audit item 8.
2026-07-06 10:25:44 +02:00

265 lines
8.6 KiB
Python

"""Pry — Human-in-the-Loop Validation Workflow.
Routes low-confidence extractions to human review before delivery."""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import json
import logging
import os
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
REVIEW_DIR = PRY_DATA_DIR / "reviews"
REVIEW_DIR.mkdir(parents=True, exist_ok=True)
# Status enum for review items
REVIEW_STATUS = ["pending", "approved", "rejected", "escalated"]
def _review_path(review_id: str) -> Path:
return REVIEW_DIR / f"{review_id}.json"
async def submit_for_review(
data: dict[str, Any],
extraction_url: str,
schema_name: str | None = None,
confidence_score: float = 0.0,
flagged_fields: list[dict[str, Any]] | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Submit extracted data for human review.
Args:
data: The extracted data that needs review
extraction_url: The source URL
schema_name: The schema/vertical used for extraction
confidence_score: Overall confidence score (0-1)
flagged_fields: Specific fields with issues
metadata: Additional context
Returns the review item with a unique ID.
"""
review_id = uuid.uuid4().hex[:12]
review_item = {
"id": review_id,
"status": "pending",
"created_at": datetime.now(UTC).isoformat(),
"updated_at": datetime.now(UTC).isoformat(),
"extraction_url": extraction_url,
"schema_name": schema_name or "unknown",
"confidence_score": round(confidence_score, 2),
"flagged_fields": flagged_fields or [],
"data": data,
"metadata": metadata or {},
"reviewed_by": None,
"reviewed_at": None,
"review_notes": "",
}
path = _review_path(review_id)
try:
path.write_text(json.dumps(review_item, indent=2))
logger.info(
"review_submitted",
extra={
"review_id": review_id,
"confidence": confidence_score,
"flagged_fields": len(flagged_fields or []),
},
)
return review_item
except OSError as e:
logger.exception("review_save_failed")
return {"error": str(e)}
async def approve_review(
review_id: str,
reviewer: str = "system",
notes: str = "",
) -> dict[str, Any]:
"""Approve a review item, allowing data to proceed to delivery."""
path = _review_path(review_id)
if not path.exists():
return {"error": f"Review not found: {review_id}"}
try:
item = cast(dict[str, Any], json.loads(path.read_text()))
item["status"] = "approved"
item["reviewed_by"] = reviewer
item["reviewed_at"] = datetime.now(UTC).isoformat()
item["review_notes"] = notes
item["updated_at"] = datetime.now(UTC).isoformat()
path.write_text(json.dumps(item, indent=2))
logger.info("review_approved", extra={"review_id": review_id, "reviewer": reviewer})
return item
except (json.JSONDecodeError, OSError) as e:
return {"error": str(e)}
async def reject_review(
review_id: str,
reviewer: str = "system",
notes: str = "",
) -> dict[str, Any]:
"""Reject a review item, blocking data delivery."""
path = _review_path(review_id)
if not path.exists():
return {"error": f"Review not found: {review_id}"}
try:
item = cast(dict[str, Any], json.loads(path.read_text()))
item["status"] = "rejected"
item["reviewed_by"] = reviewer
item["reviewed_at"] = datetime.now(UTC).isoformat()
item["review_notes"] = notes
item["updated_at"] = datetime.now(UTC).isoformat()
path.write_text(json.dumps(item, indent=2))
logger.info("review_rejected", extra={"review_id": review_id, "reviewer": reviewer})
return item
except (json.JSONDecodeError, OSError) as e:
return {"error": str(e)}
def get_review_queue(status: str | None = None) -> list[dict[str, Any]]:
"""Get the review queue, optionally filtered by status."""
reviews = []
for path in sorted(REVIEW_DIR.glob("*.json"), key=os.path.getmtime, reverse=True):
try:
item = json.loads(path.read_text())
if status and item.get("status") != status:
continue
# Return summary without full data payload
reviews.append(
{
"id": item["id"],
"status": item["status"],
"created_at": item["created_at"],
"extraction_url": item["extraction_url"],
"schema_name": item["schema_name"],
"confidence_score": item["confidence_score"],
"flagged_fields": item["flagged_fields"],
"flagged_field_count": len(item.get("flagged_fields", [])),
"reviewed_by": item.get("reviewed_by"),
"reviewed_at": item.get("reviewed_at"),
}
)
except (json.JSONDecodeError, OSError):
continue
return reviews
def get_review_detail(review_id: str) -> dict[str, Any] | None:
"""Get full review item detail including data."""
path = _review_path(review_id)
if not path.exists():
return None
try:
return cast(dict[str, Any], json.loads(path.read_text()))
except (json.JSONDecodeError, OSError):
return None
async def notify_slack_review(
webhook_url: str,
review_id: str,
extraction_url: str,
confidence_score: float,
flagged_fields: list[dict[str, Any]],
) -> dict[str, Any]:
"""Send a Slack notification for a pending review."""
from destinations import write_to_slack
field_details = "\n".join(
f"{f.get('field', 'unknown')}: {f.get('issue', 'flagged')}"
for f in (flagged_fields or [])[:5]
)
message = (
f"*New Review Requested*\n"
f"• *Review ID:* `{review_id}`\n"
f"• *URL:* {extraction_url}\n"
f"• *Confidence:* {confidence_score}\n"
f"• *Flagged Fields:*\n{field_details}\n\n"
f"Approve: `POST /v1/review/{review_id}/approve`\n"
f"Reject: `POST /v1/review/{review_id}/reject`"
)
return await write_to_slack(
webhook_url, message, title="Pry — Human Review Required", color="#ffa500"
)
async def auto_review_threshold(
data: dict[str, Any],
extraction_url: str,
quality_result: dict[str, Any],
slack_webhook: str = "",
auto_approve_threshold: float = 0.8,
auto_reject_threshold: float = 0.2,
) -> dict[str, Any]:
"""Automatically route extraction based on quality scores.
- Above auto_approve_threshold: auto-approve, deliver immediately
- Below auto_reject_threshold: auto-reject, block delivery
- In between: route to human review
"""
quality_score = quality_result.get("quality_score", 50) / 100.0
anomalies = quality_result.get("anomalies", [])
critical_anomalies = quality_result.get("critical_anomalies", 0)
# Adjust score down for critical anomalies
if critical_anomalies > 0:
quality_score *= max(0.1, 1.0 - (critical_anomalies * 0.3))
flagged_fields = [
{
"field": a.get("field", "unknown"),
"issue": a.get("message", "Flagged"),
"severity": a.get("severity", "medium"),
}
for a in anomalies[:10]
]
if quality_score >= auto_approve_threshold:
return {
"decision": "approved",
"reason": f"Quality score {quality_score:.0%} above auto-approve threshold",
"review_id": None,
}
if quality_score < auto_reject_threshold:
return {
"decision": "rejected",
"reason": f"Quality score {quality_score:.0%} below auto-reject threshold",
"review_id": None,
}
# Submit for human review
review = await submit_for_review(
data=data,
extraction_url=extraction_url,
confidence_score=quality_score,
flagged_fields=flagged_fields,
)
review_id = review.get("id", "")
# Notify Slack if configured
if slack_webhook and review_id:
await notify_slack_review(
slack_webhook, review_id, extraction_url, quality_score, flagged_fields
)
return {
"decision": "review_required",
"reason": f"Quality score {quality_score:.0%} needs human review",
"review_id": review_id,
"review": review,
}