Commit Β·
42747f7
0
Parent(s):
Sync: Can now hide duplicate pages or Excel/Word file redaction tabs
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .coveragerc +56 -0
- .dockerignore +58 -0
- .gitattributes +9 -0
- .github/scripts/setup_test_data.py +320 -0
- .github/workflow_README.md +183 -0
- .github/workflows/archive_workflows/multi-os-test.yml +115 -0
- .github/workflows/ci.yml +269 -0
- .github/workflows/simple-test.yml +74 -0
- .github/workflows/sync-pi-agent-space.yml +64 -0
- .github/workflows/sync_to_hf.yml +54 -0
- .github/workflows/sync_to_hf_zero_gpu.yml +59 -0
- .gitignore +75 -0
- AGENTS.md +113 -0
- Dockerfile +235 -0
- LICENSE +661 -0
- MANIFEST.in +4 -0
- README.md +367 -0
- README_PYPI.md +351 -0
- agent-redact/README.md +33 -0
- agent-redact/agentcore/Dockerfile.runtime +38 -0
- agent-redact/agentcore/README.md +470 -0
- agent-redact/agentcore/agentcore_boto.py +47 -0
- agent-redact/agentcore/agentcore_harness_runtime.py +270 -0
- agent-redact/agentcore/agentcore_runtime.py +406 -0
- agent-redact/agentcore/agentcore_workspace_bridge.py +359 -0
- agent-redact/agentcore/bundle_support/session_workspace.py +50 -0
- agent-redact/agentcore/entrypoint.py +39 -0
- agent-redact/agentcore/harness_input_bridge.py +111 -0
- agent-redact/agentcore/invoke_agent.py +288 -0
- agent-redact/agentcore/package_runtime.py +297 -0
- agent-redact/agentcore/session_store.py +60 -0
- agent-redact/agentcore/workspace_sync.py +83 -0
- agent-redact/eval/__init__.py +1 -0
- agent-redact/eval/arize_monitoring.py +222 -0
- agent-redact/pi-agent/.dockerignore +11 -0
- agent-redact/pi-agent/.gitattributes +2 -0
- agent-redact/pi-agent/Dockerfile +179 -0
- agent-redact/pi-agent/README.md +46 -0
- agent-redact/pi-agent/entrypoint-ecs.sh +12 -0
- agent-redact/pi-agent/entrypoint.sh +36 -0
- agent-redact/pi-agent/sync-manifest.txt +13 -0
- agent-redact/pi-agent/sync_to_space.sh +42 -0
- agent-redact/pi/agent/README.md +223 -0
- agent-redact/pi/agent/models.json +31 -0
- agent-redact/pi/agent/settings.json +32 -0
- agent-redact/pi/pi_agent_config.py +974 -0
- agent-redact/pi/pi_rpc_client.py +1016 -0
- agent-redact/pi/pi_session_usage.py +190 -0
- agent-redact/pi/pi_workspace_skills.py +397 -0
- agent-redact/redaction_langgraph/__init__.py +1 -0
.coveragerc
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[run]
|
| 2 |
+
source = .
|
| 3 |
+
omit =
|
| 4 |
+
*/tests/*
|
| 5 |
+
*/test/*
|
| 6 |
+
*/__pycache__/*
|
| 7 |
+
*/venv/*
|
| 8 |
+
*/env/*
|
| 9 |
+
*/build/*
|
| 10 |
+
*/dist/*
|
| 11 |
+
*/cdk/*
|
| 12 |
+
*/docs/*
|
| 13 |
+
*/example_data/*
|
| 14 |
+
*/examples/*
|
| 15 |
+
*/feedback/*
|
| 16 |
+
*/logs/*
|
| 17 |
+
*/old_code/*
|
| 18 |
+
*/output/*
|
| 19 |
+
*/tmp/*
|
| 20 |
+
*/usage/*
|
| 21 |
+
*/tld/*
|
| 22 |
+
*/tesseract/*
|
| 23 |
+
*/poppler/*
|
| 24 |
+
config*.py
|
| 25 |
+
setup.py
|
| 26 |
+
lambda_entrypoint.py
|
| 27 |
+
entrypoint.sh
|
| 28 |
+
cli_redact.py
|
| 29 |
+
load_dynamo_logs.py
|
| 30 |
+
load_s3_logs.py
|
| 31 |
+
*.spec
|
| 32 |
+
Dockerfile
|
| 33 |
+
*.qmd
|
| 34 |
+
*.md
|
| 35 |
+
*.txt
|
| 36 |
+
*.yml
|
| 37 |
+
*.yaml
|
| 38 |
+
*.json
|
| 39 |
+
*.csv
|
| 40 |
+
*.env
|
| 41 |
+
*.bat
|
| 42 |
+
*.ps1
|
| 43 |
+
*.sh
|
| 44 |
+
|
| 45 |
+
[report]
|
| 46 |
+
exclude_lines =
|
| 47 |
+
pragma: no cover
|
| 48 |
+
def __repr__
|
| 49 |
+
if self.debug:
|
| 50 |
+
if settings.DEBUG
|
| 51 |
+
raise AssertionError
|
| 52 |
+
raise NotImplementedError
|
| 53 |
+
if 0:
|
| 54 |
+
if __name__ == .__main__.:
|
| 55 |
+
class .*\bProtocol\):
|
| 56 |
+
@(abc\.)?abstractmethod
|
.dockerignore
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.url
|
| 2 |
+
*.ipynb
|
| 3 |
+
*.pyc
|
| 4 |
+
*.qmd
|
| 5 |
+
*.json.bak.*
|
| 6 |
+
_quarto.yml
|
| 7 |
+
quarto_site/*
|
| 8 |
+
src/*
|
| 9 |
+
redaction_deps/*
|
| 10 |
+
.venv/*
|
| 11 |
+
examples/*
|
| 12 |
+
processing/*
|
| 13 |
+
tools/__pycache__/*
|
| 14 |
+
old_code/*
|
| 15 |
+
tesseract/*
|
| 16 |
+
poppler/*
|
| 17 |
+
build/*
|
| 18 |
+
dist/*
|
| 19 |
+
docs/*
|
| 20 |
+
.pi/*
|
| 21 |
+
build_deps/*
|
| 22 |
+
user_guide/*
|
| 23 |
+
_extensions/*
|
| 24 |
+
workspace/*
|
| 25 |
+
doc_redaction.egg-info/*
|
| 26 |
+
.venv_pypi_test/*
|
| 27 |
+
cdk/config/*
|
| 28 |
+
tld/*
|
| 29 |
+
cdk/config/*
|
| 30 |
+
cdk/cdk.out/*
|
| 31 |
+
cdk/archive/*
|
| 32 |
+
cdk.json
|
| 33 |
+
cdk.context.json
|
| 34 |
+
.quarto/*
|
| 35 |
+
logs/
|
| 36 |
+
output/
|
| 37 |
+
input/
|
| 38 |
+
feedback/
|
| 39 |
+
# Exclude local secrets; allow committed *.example templates (Pi agent + main app images).
|
| 40 |
+
config/*
|
| 41 |
+
!config/agent.env.example
|
| 42 |
+
!config/app_config.env.example
|
| 43 |
+
!config/docker_app_config.env.example
|
| 44 |
+
usage/
|
| 45 |
+
test/config/*
|
| 46 |
+
test/feedback/*
|
| 47 |
+
test/input/*
|
| 48 |
+
test/logs/*
|
| 49 |
+
test/output/*
|
| 50 |
+
test/tmp/*
|
| 51 |
+
test/usage/*
|
| 52 |
+
.ruff_cache/*
|
| 53 |
+
model_cache/*
|
| 54 |
+
sanitized_file/*
|
| 55 |
+
src/doc_redaction.egg-info/*
|
| 56 |
+
docker_compose/*
|
| 57 |
+
skills/example_prompts/*
|
| 58 |
+
agent-redact/RedactionAgent/*
|
.gitattributes
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.pdf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.sh text eol=lf
|
| 3 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.xls filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.xlsx filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.docx filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.doc filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.ico filter=lfs diff=lfs merge=lfs -text
|
.github/scripts/setup_test_data.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Setup script for GitHub Actions test data.
|
| 4 |
+
Creates dummy test files when example data is not available.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def create_directories():
|
| 14 |
+
"""Create necessary directories."""
|
| 15 |
+
dirs = ["doc_redaction/example_data", "doc_redaction/example_data/example_outputs"]
|
| 16 |
+
|
| 17 |
+
for dir_path in dirs:
|
| 18 |
+
os.makedirs(dir_path, exist_ok=True)
|
| 19 |
+
print(f"Created directory: {dir_path}")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def create_dummy_pdf():
|
| 23 |
+
"""Create dummy PDFs for testing."""
|
| 24 |
+
|
| 25 |
+
# Install reportlab if not available
|
| 26 |
+
try:
|
| 27 |
+
from reportlab.lib.pagesizes import letter
|
| 28 |
+
from reportlab.pdfgen import canvas
|
| 29 |
+
except ImportError:
|
| 30 |
+
import subprocess
|
| 31 |
+
|
| 32 |
+
subprocess.check_call(["pip", "install", "reportlab"])
|
| 33 |
+
from reportlab.lib.pagesizes import letter
|
| 34 |
+
from reportlab.pdfgen import canvas
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
# Create the main test PDF
|
| 38 |
+
pdf_path = "doc_redaction/example_data/example_of_emails_sent_to_a_professor_before_applying.pdf"
|
| 39 |
+
print(f"Creating PDF: {pdf_path}")
|
| 40 |
+
print(f"Directory exists: {os.path.exists('doc_redaction/example_data')}")
|
| 41 |
+
|
| 42 |
+
c = canvas.Canvas(pdf_path, pagesize=letter)
|
| 43 |
+
c.drawString(100, 750, "This is a test document for redaction testing.")
|
| 44 |
+
c.drawString(100, 700, "Email: test@example.com")
|
| 45 |
+
c.drawString(100, 650, "Phone: 123-456-7890")
|
| 46 |
+
c.drawString(100, 600, "Name: John Doe")
|
| 47 |
+
c.drawString(100, 550, "Address: 123 Test Street, Test City, TC 12345")
|
| 48 |
+
c.showPage()
|
| 49 |
+
|
| 50 |
+
# Add second page
|
| 51 |
+
c.drawString(100, 750, "Second page content")
|
| 52 |
+
c.drawString(100, 700, "More test data: jane.doe@example.com")
|
| 53 |
+
c.drawString(100, 650, "Another phone: 987-654-3210")
|
| 54 |
+
c.save()
|
| 55 |
+
|
| 56 |
+
print(f"Created dummy PDF: {pdf_path}")
|
| 57 |
+
|
| 58 |
+
# Create Partnership Agreement Toolkit PDF
|
| 59 |
+
partnership_pdf_path = (
|
| 60 |
+
"doc_redaction/example_data/Partnership-Agreement-Toolkit_0_0.pdf"
|
| 61 |
+
)
|
| 62 |
+
print(f"Creating PDF: {partnership_pdf_path}")
|
| 63 |
+
c = canvas.Canvas(partnership_pdf_path, pagesize=letter)
|
| 64 |
+
c.drawString(100, 750, "Partnership Agreement Toolkit")
|
| 65 |
+
c.drawString(100, 700, "This is a test partnership agreement document.")
|
| 66 |
+
c.drawString(100, 650, "Contact: partnership@example.com")
|
| 67 |
+
c.drawString(100, 600, "Phone: (555) 123-4567")
|
| 68 |
+
c.drawString(100, 550, "Address: 123 Partnership Street, City, State 12345")
|
| 69 |
+
c.showPage()
|
| 70 |
+
|
| 71 |
+
# Add second page
|
| 72 |
+
c.drawString(100, 750, "Page 2 - Partnership Details")
|
| 73 |
+
c.drawString(100, 700, "More partnership information here.")
|
| 74 |
+
c.drawString(100, 650, "Contact: info@partnership.org")
|
| 75 |
+
c.showPage()
|
| 76 |
+
|
| 77 |
+
# Add third page
|
| 78 |
+
c.drawString(100, 750, "Page 3 - Terms and Conditions")
|
| 79 |
+
c.drawString(100, 700, "Terms and conditions content.")
|
| 80 |
+
c.drawString(100, 650, "Legal contact: legal@partnership.org")
|
| 81 |
+
c.save()
|
| 82 |
+
|
| 83 |
+
print(f"Created dummy PDF: {partnership_pdf_path}")
|
| 84 |
+
|
| 85 |
+
# Create Graduate Job Cover Letter PDF
|
| 86 |
+
cover_letter_pdf_path = (
|
| 87 |
+
"doc_redaction/example_data/graduate-job-example-cover-letter.pdf"
|
| 88 |
+
)
|
| 89 |
+
print(f"Creating PDF: {cover_letter_pdf_path}")
|
| 90 |
+
c = canvas.Canvas(cover_letter_pdf_path, pagesize=letter)
|
| 91 |
+
c.drawString(100, 750, "Cover Letter Example")
|
| 92 |
+
c.drawString(100, 700, "Dear Hiring Manager,")
|
| 93 |
+
c.drawString(100, 650, "I am writing to apply for the position.")
|
| 94 |
+
c.drawString(100, 600, "Contact: applicant@example.com")
|
| 95 |
+
c.drawString(100, 550, "Phone: (555) 987-6543")
|
| 96 |
+
c.drawString(100, 500, "Address: 456 Job Street, Employment City, EC 54321")
|
| 97 |
+
c.drawString(100, 450, "Sincerely,")
|
| 98 |
+
c.drawString(100, 400, "John Applicant")
|
| 99 |
+
c.save()
|
| 100 |
+
|
| 101 |
+
print(f"Created dummy PDF: {cover_letter_pdf_path}")
|
| 102 |
+
|
| 103 |
+
except ImportError:
|
| 104 |
+
print("ReportLab not available, skipping PDF creation")
|
| 105 |
+
# Create simple text files instead
|
| 106 |
+
with open(
|
| 107 |
+
"doc_redaction/example_data/example_of_emails_sent_to_a_professor_before_applying.pdf",
|
| 108 |
+
"w",
|
| 109 |
+
) as f:
|
| 110 |
+
f.write("This is a dummy PDF file for testing")
|
| 111 |
+
|
| 112 |
+
with open(
|
| 113 |
+
"doc_redaction/example_data/Partnership-Agreement-Toolkit_0_0.pdf",
|
| 114 |
+
"w",
|
| 115 |
+
) as f:
|
| 116 |
+
f.write("This is a dummy Partnership Agreement PDF file for testing")
|
| 117 |
+
|
| 118 |
+
with open(
|
| 119 |
+
"doc_redaction/example_data/graduate-job-example-cover-letter.pdf",
|
| 120 |
+
"w",
|
| 121 |
+
) as f:
|
| 122 |
+
f.write("This is a dummy cover letter PDF file for testing")
|
| 123 |
+
|
| 124 |
+
print("Created dummy text files instead of PDFs")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def create_dummy_csv():
|
| 128 |
+
"""Create dummy CSV files for testing."""
|
| 129 |
+
# Main CSV
|
| 130 |
+
csv_data = {
|
| 131 |
+
"Case Note": [
|
| 132 |
+
"Client visited for consultation regarding housing issues",
|
| 133 |
+
"Follow-up appointment scheduled for next week",
|
| 134 |
+
"Documentation submitted for review",
|
| 135 |
+
],
|
| 136 |
+
"Client": ["John Smith", "Jane Doe", "Bob Johnson"],
|
| 137 |
+
"Date": ["2024-01-15", "2024-01-16", "2024-01-17"],
|
| 138 |
+
}
|
| 139 |
+
df = pd.DataFrame(csv_data)
|
| 140 |
+
df.to_csv("doc_redaction/example_data/combined_case_notes.csv", index=False)
|
| 141 |
+
print("Created dummy CSV: doc_redaction/example_data/combined_case_notes.csv")
|
| 142 |
+
|
| 143 |
+
# Lambeth CSV
|
| 144 |
+
lambeth_data = {
|
| 145 |
+
"text": [
|
| 146 |
+
"Lambeth 2030 vision document content",
|
| 147 |
+
"Our Future Our Lambeth strategic plan",
|
| 148 |
+
"Community engagement and development",
|
| 149 |
+
],
|
| 150 |
+
"page": [1, 2, 3],
|
| 151 |
+
}
|
| 152 |
+
df_lambeth = pd.DataFrame(lambeth_data)
|
| 153 |
+
df_lambeth.to_csv(
|
| 154 |
+
"doc_redaction/example_data/Lambeth_2030-Our_Future_Our_Lambeth.pdf.csv",
|
| 155 |
+
index=False,
|
| 156 |
+
)
|
| 157 |
+
print(
|
| 158 |
+
"Created dummy CSV: doc_redaction/example_data/Lambeth_2030-Our_Future_Our_Lambeth.pdf.csv"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def create_dummy_word_doc():
|
| 163 |
+
"""Create dummy Word document."""
|
| 164 |
+
try:
|
| 165 |
+
from docx import Document
|
| 166 |
+
|
| 167 |
+
doc = Document()
|
| 168 |
+
doc.add_heading("Test Document for Redaction", 0)
|
| 169 |
+
doc.add_paragraph("This is a test document for redaction testing.")
|
| 170 |
+
doc.add_paragraph("Contact Information:")
|
| 171 |
+
doc.add_paragraph("Email: test@example.com")
|
| 172 |
+
doc.add_paragraph("Phone: 123-456-7890")
|
| 173 |
+
doc.add_paragraph("Name: John Doe")
|
| 174 |
+
doc.add_paragraph("Address: 123 Test Street, Test City, TC 12345")
|
| 175 |
+
|
| 176 |
+
doc.save(
|
| 177 |
+
"doc_redaction/example_data/Bold minimalist professional cover letter.docx"
|
| 178 |
+
)
|
| 179 |
+
print("Created dummy Word document")
|
| 180 |
+
|
| 181 |
+
except ImportError:
|
| 182 |
+
print("python-docx not available, skipping Word document creation")
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def create_allow_deny_lists():
|
| 186 |
+
"""Create dummy allow/deny lists."""
|
| 187 |
+
# Allow lists
|
| 188 |
+
allow_data = {"word": ["test", "example", "document"]}
|
| 189 |
+
pd.DataFrame(allow_data).to_csv(
|
| 190 |
+
"doc_redaction/example_data/test_allow_list_graduate.csv", index=False
|
| 191 |
+
)
|
| 192 |
+
pd.DataFrame(allow_data).to_csv(
|
| 193 |
+
"doc_redaction/example_data/test_allow_list_partnership.csv", index=False
|
| 194 |
+
)
|
| 195 |
+
print("Created allow lists")
|
| 196 |
+
|
| 197 |
+
# Deny lists
|
| 198 |
+
deny_data = {"word": ["sensitive", "confidential", "private"]}
|
| 199 |
+
pd.DataFrame(deny_data).to_csv(
|
| 200 |
+
"doc_redaction/example_data/partnership_toolkit_redact_custom_deny_list.csv",
|
| 201 |
+
index=False,
|
| 202 |
+
)
|
| 203 |
+
pd.DataFrame(deny_data).to_csv(
|
| 204 |
+
"doc_redaction/example_data/Partnership-Agreement-Toolkit_test_deny_list_para_single_spell.csv",
|
| 205 |
+
index=False,
|
| 206 |
+
)
|
| 207 |
+
print("Created deny lists")
|
| 208 |
+
|
| 209 |
+
# Whole page redaction list
|
| 210 |
+
page_data = {"page": [1, 2]}
|
| 211 |
+
pd.DataFrame(page_data).to_csv(
|
| 212 |
+
"doc_redaction/example_data/partnership_toolkit_redact_some_pages.csv",
|
| 213 |
+
index=False,
|
| 214 |
+
)
|
| 215 |
+
print("Created whole page redaction list")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def create_ocr_output():
|
| 219 |
+
"""Create dummy OCR output CSV."""
|
| 220 |
+
ocr_data = {
|
| 221 |
+
"page": [1, 2, 3],
|
| 222 |
+
"text": [
|
| 223 |
+
"This is page 1 content with some text",
|
| 224 |
+
"This is page 2 content with different text",
|
| 225 |
+
"This is page 3 content with more text",
|
| 226 |
+
],
|
| 227 |
+
"left": [0.1, 0.3, 0.5],
|
| 228 |
+
"top": [0.95, 0.92, 0.88],
|
| 229 |
+
"width": [0.05, 0.02, 0.02],
|
| 230 |
+
"height": [0.01, 0.02, 0.02],
|
| 231 |
+
"line": [1, 2, 3],
|
| 232 |
+
}
|
| 233 |
+
df = pd.DataFrame(ocr_data)
|
| 234 |
+
df.to_csv(
|
| 235 |
+
"doc_redaction/example_data/example_outputs/doubled_output_joined.pdf_ocr_output.csv",
|
| 236 |
+
index=False,
|
| 237 |
+
)
|
| 238 |
+
print("Created dummy OCR output CSV")
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def create_dummy_image():
|
| 242 |
+
"""Create dummy image for testing."""
|
| 243 |
+
try:
|
| 244 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 245 |
+
|
| 246 |
+
img = Image.new("RGB", (800, 600), color="white")
|
| 247 |
+
draw = ImageDraw.Draw(img)
|
| 248 |
+
|
| 249 |
+
# Try to use a system font
|
| 250 |
+
try:
|
| 251 |
+
font = ImageFont.truetype(
|
| 252 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20
|
| 253 |
+
)
|
| 254 |
+
except Exception as e:
|
| 255 |
+
print(f"Error loading DejaVuSans font: {e}")
|
| 256 |
+
try:
|
| 257 |
+
font = ImageFont.truetype("/System/Library/Fonts/Arial.ttf", 20)
|
| 258 |
+
except Exception as e:
|
| 259 |
+
print(f"Error loading Arial font: {e}")
|
| 260 |
+
font = ImageFont.load_default()
|
| 261 |
+
|
| 262 |
+
# Add text to image
|
| 263 |
+
draw.text((50, 50), "Test Document for Redaction", fill="black", font=font)
|
| 264 |
+
draw.text((50, 100), "Email: test@example.com", fill="black", font=font)
|
| 265 |
+
draw.text((50, 150), "Phone: 123-456-7890", fill="black", font=font)
|
| 266 |
+
draw.text((50, 200), "Name: John Doe", fill="black", font=font)
|
| 267 |
+
draw.text((50, 250), "Address: 123 Test Street", fill="black", font=font)
|
| 268 |
+
|
| 269 |
+
img.save("doc_redaction/example_data/example_complaint_letter.jpg")
|
| 270 |
+
print("Created dummy image")
|
| 271 |
+
|
| 272 |
+
except ImportError:
|
| 273 |
+
print("PIL not available, skipping image creation")
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def main():
|
| 277 |
+
"""Main setup function."""
|
| 278 |
+
print("Setting up test data for GitHub Actions...")
|
| 279 |
+
print(f"Current working directory: {os.getcwd()}")
|
| 280 |
+
print(f"Python version: {sys.version}")
|
| 281 |
+
|
| 282 |
+
create_directories()
|
| 283 |
+
create_dummy_pdf()
|
| 284 |
+
create_dummy_csv()
|
| 285 |
+
create_dummy_word_doc()
|
| 286 |
+
create_allow_deny_lists()
|
| 287 |
+
create_ocr_output()
|
| 288 |
+
create_dummy_image()
|
| 289 |
+
|
| 290 |
+
print("\nTest data setup complete!")
|
| 291 |
+
print("Created files:")
|
| 292 |
+
for root, dirs, files in os.walk("doc_redaction/example_data"):
|
| 293 |
+
for file in files:
|
| 294 |
+
file_path = os.path.join(root, file)
|
| 295 |
+
print(f" {file_path}")
|
| 296 |
+
# Verify the file exists and has content
|
| 297 |
+
if os.path.exists(file_path):
|
| 298 |
+
file_size = os.path.getsize(file_path)
|
| 299 |
+
print(f" Size: {file_size} bytes")
|
| 300 |
+
else:
|
| 301 |
+
print(" WARNING: File does not exist!")
|
| 302 |
+
|
| 303 |
+
# Verify critical files exist
|
| 304 |
+
critical_files = [
|
| 305 |
+
"doc_redaction/example_data/Partnership-Agreement-Toolkit_0_0.pdf",
|
| 306 |
+
"doc_redaction/example_data/graduate-job-example-cover-letter.pdf",
|
| 307 |
+
"doc_redaction/example_data/example_of_emails_sent_to_a_professor_before_applying.pdf",
|
| 308 |
+
]
|
| 309 |
+
|
| 310 |
+
print("\nVerifying critical test files:")
|
| 311 |
+
for file_path in critical_files:
|
| 312 |
+
if os.path.exists(file_path):
|
| 313 |
+
file_size = os.path.getsize(file_path)
|
| 314 |
+
print(f"β
{file_path} exists ({file_size} bytes)")
|
| 315 |
+
else:
|
| 316 |
+
print(f"β {file_path} MISSING!")
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
if __name__ == "__main__":
|
| 320 |
+
main()
|
.github/workflow_README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GitHub Actions CI/CD Setup
|
| 2 |
+
|
| 3 |
+
This directory contains GitHub Actions workflows for automated testing of the CLI redaction application.
|
| 4 |
+
|
| 5 |
+
## Workflows Overview
|
| 6 |
+
|
| 7 |
+
### 1. **Simple Test Run** (`.github/workflows/simple-test.yml`)
|
| 8 |
+
- **Purpose**: Basic test execution
|
| 9 |
+
- **Triggers**: Push to main/dev, Pull requests
|
| 10 |
+
- **OS**: Ubuntu Latest
|
| 11 |
+
- **Python**: 3.11
|
| 12 |
+
- **Features**:
|
| 13 |
+
- Installs system dependencies
|
| 14 |
+
- Sets up test data
|
| 15 |
+
- Runs CLI tests
|
| 16 |
+
- Runs pytest
|
| 17 |
+
|
| 18 |
+
### 2. **Comprehensive CI/CD** (`.github/workflows/ci.yml`)
|
| 19 |
+
- **Purpose**: Full CI/CD pipeline
|
| 20 |
+
- **Features**:
|
| 21 |
+
- Linting (Ruff, Black)
|
| 22 |
+
- Unit tests (Python 3.10, 3.11, 3.12)
|
| 23 |
+
- Integration tests
|
| 24 |
+
- Security scanning (Safety, Bandit)
|
| 25 |
+
- Coverage reporting
|
| 26 |
+
- Package building (on main branch)
|
| 27 |
+
|
| 28 |
+
### 3. **Multi-OS Testing** (`.github/workflows/multi-os-test.yml`)
|
| 29 |
+
- **Purpose**: Cross-platform testing
|
| 30 |
+
- **OS**: Ubuntu, macOS (Windows not included currently but may be reintroduced)
|
| 31 |
+
- **Python**: 3.10, 3.11, 3.12
|
| 32 |
+
- **Features**: Tests compatibility across different operating systems
|
| 33 |
+
|
| 34 |
+
### 4. **Basic Test Suite** (`.github/workflows/test.yml`)
|
| 35 |
+
- **Purpose**: Original test workflow
|
| 36 |
+
- **Features**:
|
| 37 |
+
- Multiple Python versions
|
| 38 |
+
- System dependency installation
|
| 39 |
+
- Test data creation
|
| 40 |
+
- Coverage reporting
|
| 41 |
+
|
| 42 |
+
## Setup Scripts
|
| 43 |
+
|
| 44 |
+
### Test Data Setup (`.github/scripts/setup_test_data.py`)
|
| 45 |
+
Creates dummy test files when example data is not available:
|
| 46 |
+
- PDF documents
|
| 47 |
+
- CSV files
|
| 48 |
+
- Word documents
|
| 49 |
+
- Images
|
| 50 |
+
- Allow/deny lists
|
| 51 |
+
- OCR output files
|
| 52 |
+
|
| 53 |
+
## Usage
|
| 54 |
+
|
| 55 |
+
### Running Tests Locally
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
# Install dependencies
|
| 59 |
+
pip install -r requirements.txt
|
| 60 |
+
pip install pytest pytest-cov
|
| 61 |
+
|
| 62 |
+
# Setup test data
|
| 63 |
+
python .github/scripts/setup_test_data.py
|
| 64 |
+
|
| 65 |
+
# Run tests
|
| 66 |
+
cd test
|
| 67 |
+
python cli_epilog_suite.py
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### GitHub Actions Triggers
|
| 71 |
+
|
| 72 |
+
1. **Push to main/dev**: Runs all tests
|
| 73 |
+
2. **Pull Request**: Runs tests and linting
|
| 74 |
+
3. **Daily Schedule**: Runs tests at 2 AM UTC
|
| 75 |
+
4. **Manual Trigger**: Can be triggered manually from GitHub
|
| 76 |
+
|
| 77 |
+
## Configuration
|
| 78 |
+
|
| 79 |
+
### Environment Variables
|
| 80 |
+
- `PYTHON_VERSION`: Default Python version (3.11)
|
| 81 |
+
- `PYTHONPATH`: Set automatically for test discovery
|
| 82 |
+
|
| 83 |
+
### Caching
|
| 84 |
+
- Pip dependencies are cached for faster builds
|
| 85 |
+
- Cache key based on requirements.txt hash
|
| 86 |
+
|
| 87 |
+
### Artifacts
|
| 88 |
+
- Test results (JUnit XML)
|
| 89 |
+
- Coverage reports (HTML, XML)
|
| 90 |
+
- Security reports
|
| 91 |
+
- Build artifacts (on main branch)
|
| 92 |
+
|
| 93 |
+
## Test Data
|
| 94 |
+
|
| 95 |
+
The workflows automatically create test data when example files are missing:
|
| 96 |
+
|
| 97 |
+
### Required Files Created:
|
| 98 |
+
- `example_data/example_of_emails_sent_to_a_professor_before_applying.pdf`
|
| 99 |
+
- `example_data/combined_case_notes.csv`
|
| 100 |
+
- `example_data/Bold minimalist professional cover letter.docx`
|
| 101 |
+
- `example_data/example_complaint_letter.jpg`
|
| 102 |
+
- `example_data/test_allow_list_*.csv`
|
| 103 |
+
- `example_data/partnership_toolkit_redact_*.csv`
|
| 104 |
+
- `example_data/example_outputs/doubled_output_joined.pdf_ocr_output.csv`
|
| 105 |
+
|
| 106 |
+
### Dependencies Installed:
|
| 107 |
+
- **System**: tesseract-ocr, poppler-utils, OpenGL libraries
|
| 108 |
+
- **Python**: All requirements.txt packages + pytest, reportlab, pillow
|
| 109 |
+
|
| 110 |
+
## Workflow Status
|
| 111 |
+
|
| 112 |
+
### Success Criteria:
|
| 113 |
+
- β
All tests pass
|
| 114 |
+
- β
No linting errors
|
| 115 |
+
- β
Security checks pass
|
| 116 |
+
- β
Coverage meets threshold (if configured)
|
| 117 |
+
|
| 118 |
+
### Failure Handling:
|
| 119 |
+
- Tests are designed to skip gracefully if files are missing
|
| 120 |
+
- AWS tests are expected to fail without credentials
|
| 121 |
+
- System dependency failures are handled with fallbacks
|
| 122 |
+
|
| 123 |
+
## Customization
|
| 124 |
+
|
| 125 |
+
### Adding New Tests:
|
| 126 |
+
1. Add test methods to `test/cli_epilog_suite.py` or pytest files under `test/test_*.py`
|
| 127 |
+
2. Update test data in `setup_test_data.py` if needed
|
| 128 |
+
3. Tests will automatically run in all workflows
|
| 129 |
+
|
| 130 |
+
### Modifying Workflows:
|
| 131 |
+
1. Edit the appropriate `.yml` file
|
| 132 |
+
2. Test locally first
|
| 133 |
+
3. Push to trigger the workflow
|
| 134 |
+
|
| 135 |
+
### Environment-Specific Settings:
|
| 136 |
+
- **Ubuntu**: Full system dependencies
|
| 137 |
+
- **Windows**: Python packages only
|
| 138 |
+
- **macOS**: Homebrew dependencies
|
| 139 |
+
|
| 140 |
+
## Troubleshooting
|
| 141 |
+
|
| 142 |
+
### Common Issues:
|
| 143 |
+
|
| 144 |
+
1. **Missing Dependencies**:
|
| 145 |
+
- Check system dependency installation
|
| 146 |
+
- Verify Python package versions
|
| 147 |
+
|
| 148 |
+
2. **Test Failures**:
|
| 149 |
+
- Check test data creation
|
| 150 |
+
- Verify file paths
|
| 151 |
+
- Review test output logs
|
| 152 |
+
|
| 153 |
+
3. **AWS Test Failures**:
|
| 154 |
+
- Expected without credentials
|
| 155 |
+
- Tests are designed to handle this gracefully
|
| 156 |
+
|
| 157 |
+
4. **System Dependency Issues**:
|
| 158 |
+
- Different OS have different requirements
|
| 159 |
+
- Check the specific OS section in workflows
|
| 160 |
+
|
| 161 |
+
### Debug Mode:
|
| 162 |
+
Add `--verbose` or `-v` flags to pytest commands for more detailed output.
|
| 163 |
+
|
| 164 |
+
## Security
|
| 165 |
+
|
| 166 |
+
- Dependencies are scanned with Safety
|
| 167 |
+
- Code is scanned with Bandit
|
| 168 |
+
- No secrets are exposed in logs
|
| 169 |
+
- Test data is temporary and cleaned up
|
| 170 |
+
|
| 171 |
+
## Performance
|
| 172 |
+
|
| 173 |
+
- Tests run in parallel where possible
|
| 174 |
+
- Dependencies are cached
|
| 175 |
+
- Only necessary system packages are installed
|
| 176 |
+
- Test data is created efficiently
|
| 177 |
+
|
| 178 |
+
## Monitoring
|
| 179 |
+
|
| 180 |
+
- Workflow status is visible in GitHub Actions tab
|
| 181 |
+
- Coverage reports are uploaded to Codecov
|
| 182 |
+
- Test results are available as artifacts
|
| 183 |
+
- Security reports are generated and stored
|
.github/workflows/archive_workflows/multi-os-test.yml
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Multi-OS Test
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ main ]
|
| 8 |
+
|
| 9 |
+
permissions:
|
| 10 |
+
contents: read
|
| 11 |
+
actions: read
|
| 12 |
+
|
| 13 |
+
jobs:
|
| 14 |
+
test:
|
| 15 |
+
runs-on: ${{ matrix.os }}
|
| 16 |
+
env:
|
| 17 |
+
SHOW_VLM_MODEL_OPTIONS: "False"
|
| 18 |
+
strategy:
|
| 19 |
+
matrix:
|
| 20 |
+
os: [ubuntu-latest, macos-latest] # windows-latest, not included as tesseract cannot be installed silently
|
| 21 |
+
python-version: ["3.11", "3.12", "3.13"]
|
| 22 |
+
exclude:
|
| 23 |
+
# Exclude some combinations to reduce CI time
|
| 24 |
+
#- os: windows-latest
|
| 25 |
+
# python-version: ["3.12", "3.13"]
|
| 26 |
+
- os: macos-latest
|
| 27 |
+
python-version: ["3.12", "3.13"]
|
| 28 |
+
|
| 29 |
+
steps:
|
| 30 |
+
- uses: actions/checkout@v6
|
| 31 |
+
|
| 32 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 33 |
+
uses: actions/setup-python@v6
|
| 34 |
+
with:
|
| 35 |
+
python-version: ${{ matrix.python-version }}
|
| 36 |
+
|
| 37 |
+
- name: Install system dependencies (Ubuntu)
|
| 38 |
+
if: matrix.os == 'ubuntu-latest'
|
| 39 |
+
run: |
|
| 40 |
+
sudo apt-get update
|
| 41 |
+
sudo apt-get install -y \
|
| 42 |
+
tesseract-ocr \
|
| 43 |
+
tesseract-ocr-eng \
|
| 44 |
+
poppler-utils \
|
| 45 |
+
libgl1-mesa-dri \
|
| 46 |
+
libglib2.0-0 \
|
| 47 |
+
libsm6 \
|
| 48 |
+
libxext6 \
|
| 49 |
+
libxrender-dev \
|
| 50 |
+
libgomp1
|
| 51 |
+
|
| 52 |
+
- name: Install system dependencies (macOS)
|
| 53 |
+
if: matrix.os == 'macos-latest'
|
| 54 |
+
run: |
|
| 55 |
+
brew install tesseract poppler
|
| 56 |
+
|
| 57 |
+
- name: Install system dependencies (Windows)
|
| 58 |
+
if: matrix.os == 'windows-latest'
|
| 59 |
+
run: |
|
| 60 |
+
# Create tools directory
|
| 61 |
+
if (!(Test-Path "C:\tools")) {
|
| 62 |
+
mkdir C:\tools
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
# Download and install Tesseract
|
| 66 |
+
$tesseractUrl = "https://github.com/tesseract-ocr/tesseract/releases/download/5.5.0/tesseract-ocr-w64-setup-5.5.0.20241111.exe"
|
| 67 |
+
$tesseractInstaller = "C:\tools\tesseract-installer.exe"
|
| 68 |
+
Invoke-WebRequest -Uri $tesseractUrl -OutFile $tesseractInstaller
|
| 69 |
+
|
| 70 |
+
# Install Tesseract silently
|
| 71 |
+
Start-Process -FilePath $tesseractInstaller -ArgumentList "/S", "/D=C:\tools\tesseract" -Wait
|
| 72 |
+
|
| 73 |
+
# Download and extract Poppler
|
| 74 |
+
$popplerUrl = "https://github.com/oschwartz10612/poppler-windows/releases/download/v25.07.0-0/Release-25.07.0-0.zip"
|
| 75 |
+
$popplerZip = "C:\tools\poppler.zip"
|
| 76 |
+
Invoke-WebRequest -Uri $popplerUrl -OutFile $popplerZip
|
| 77 |
+
|
| 78 |
+
# Extract Poppler
|
| 79 |
+
Expand-Archive -Path $popplerZip -DestinationPath C:\tools\poppler -Force
|
| 80 |
+
|
| 81 |
+
# Add to PATH
|
| 82 |
+
echo "C:\tools\tesseract" >> $env:GITHUB_PATH
|
| 83 |
+
echo "C:\tools\poppler\poppler-25.07.0\Library\bin" >> $env:GITHUB_PATH
|
| 84 |
+
|
| 85 |
+
# Set environment variables for your application
|
| 86 |
+
echo "TESSERACT_FOLDER=C:\tools\tesseract" >> $env:GITHUB_ENV
|
| 87 |
+
echo "POPPLER_FOLDER=C:\tools\poppler\poppler-25.07.0\Library\bin" >> $env:GITHUB_ENV
|
| 88 |
+
echo "TESSERACT_DATA_FOLDER=C:\tools\tesseract\tessdata" >> $env:GITHUB_ENV
|
| 89 |
+
|
| 90 |
+
# Verify installation using full paths (since PATH won't be updated in current session)
|
| 91 |
+
& "C:\tools\tesseract\tesseract.exe" --version
|
| 92 |
+
& "C:\tools\poppler\poppler-25.07.0\Library\bin\pdftoppm.exe" -v
|
| 93 |
+
|
| 94 |
+
- name: Install Python dependencies
|
| 95 |
+
run: |
|
| 96 |
+
python -m pip install --upgrade pip
|
| 97 |
+
pip install -r requirements.txt
|
| 98 |
+
pip install pytest pytest-cov reportlab pillow
|
| 99 |
+
|
| 100 |
+
- name: Download spaCy model
|
| 101 |
+
run: |
|
| 102 |
+
python -m spacy download en_core_web_lg
|
| 103 |
+
|
| 104 |
+
- name: Setup test data
|
| 105 |
+
run: |
|
| 106 |
+
python .github/scripts/setup_test_data.py
|
| 107 |
+
|
| 108 |
+
- name: Run CLI tests
|
| 109 |
+
run: |
|
| 110 |
+
cd test
|
| 111 |
+
python cli_epilog_suite.py
|
| 112 |
+
|
| 113 |
+
- name: Run tests with pytest
|
| 114 |
+
run: |
|
| 115 |
+
pytest test/ -v --tb=short
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI/CD Pipeline
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ main ]
|
| 8 |
+
workflow_dispatch:
|
| 9 |
+
#schedule:
|
| 10 |
+
# Run tests daily at 2 AM UTC
|
| 11 |
+
# - cron: '0 2 * * *'
|
| 12 |
+
|
| 13 |
+
permissions:
|
| 14 |
+
contents: read
|
| 15 |
+
actions: read
|
| 16 |
+
pull-requests: write
|
| 17 |
+
issues: write
|
| 18 |
+
|
| 19 |
+
env:
|
| 20 |
+
PYTHON_VERSION: "3.11"
|
| 21 |
+
|
| 22 |
+
jobs:
|
| 23 |
+
lint:
|
| 24 |
+
runs-on: ubuntu-latest
|
| 25 |
+
steps:
|
| 26 |
+
- uses: actions/checkout@v6
|
| 27 |
+
|
| 28 |
+
- name: Set up Python
|
| 29 |
+
uses: actions/setup-python@v6
|
| 30 |
+
with:
|
| 31 |
+
python-version: ${{ env.PYTHON_VERSION }}
|
| 32 |
+
|
| 33 |
+
- name: Install dependencies
|
| 34 |
+
run: |
|
| 35 |
+
python -m pip install --upgrade pip
|
| 36 |
+
pip install ruff black
|
| 37 |
+
|
| 38 |
+
- name: Run Ruff linter
|
| 39 |
+
run: ruff check .
|
| 40 |
+
|
| 41 |
+
- name: Run Black formatter check
|
| 42 |
+
run: black --check .
|
| 43 |
+
|
| 44 |
+
test-unit:
|
| 45 |
+
runs-on: ubuntu-latest
|
| 46 |
+
env:
|
| 47 |
+
# Avoid optional VLM/torch import path in tools.run_vlm (not installed in lightweight CI deps)
|
| 48 |
+
SHOW_VLM_MODEL_OPTIONS: "False"
|
| 49 |
+
strategy:
|
| 50 |
+
matrix:
|
| 51 |
+
python-version: [3.11, 3.12, 3.13]
|
| 52 |
+
|
| 53 |
+
steps:
|
| 54 |
+
- uses: actions/checkout@v6
|
| 55 |
+
|
| 56 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 57 |
+
uses: actions/setup-python@v6
|
| 58 |
+
with:
|
| 59 |
+
python-version: ${{ matrix.python-version }}
|
| 60 |
+
|
| 61 |
+
- name: Cache pip dependencies
|
| 62 |
+
uses: actions/cache@v5
|
| 63 |
+
with:
|
| 64 |
+
path: ~/.cache/pip
|
| 65 |
+
key: ${{ runner.os }}-pip-${{ hashFiles('requirements_lightweight.txt') }}
|
| 66 |
+
restore-keys: |
|
| 67 |
+
${{ runner.os }}-pip-
|
| 68 |
+
|
| 69 |
+
- name: Install system dependencies
|
| 70 |
+
run: |
|
| 71 |
+
sudo apt-get update
|
| 72 |
+
sudo apt-get install -y \
|
| 73 |
+
tesseract-ocr \
|
| 74 |
+
tesseract-ocr-eng \
|
| 75 |
+
poppler-utils \
|
| 76 |
+
libgl1-mesa-dri \
|
| 77 |
+
libglib2.0-0 \
|
| 78 |
+
libsm6 \
|
| 79 |
+
libxext6 \
|
| 80 |
+
libxrender-dev \
|
| 81 |
+
libgomp1
|
| 82 |
+
|
| 83 |
+
- name: Install Python dependencies
|
| 84 |
+
run: |
|
| 85 |
+
python -m pip install --upgrade pip
|
| 86 |
+
pip install -r requirements_lightweight.txt
|
| 87 |
+
pip install pytest pytest-cov pytest-html pytest-xdist reportlab pillow
|
| 88 |
+
|
| 89 |
+
- name: Download spaCy model
|
| 90 |
+
run: |
|
| 91 |
+
python -m spacy download en_core_web_lg
|
| 92 |
+
|
| 93 |
+
- name: Setup test data
|
| 94 |
+
run: |
|
| 95 |
+
python .github/scripts/setup_test_data.py
|
| 96 |
+
echo "Setup script completed. Checking results:"
|
| 97 |
+
ls -la doc_redaction/example_data/ || echo "doc_redaction/example_data directory not found"
|
| 98 |
+
|
| 99 |
+
- name: Verify test data files
|
| 100 |
+
run: |
|
| 101 |
+
echo "Checking if critical test files exist:"
|
| 102 |
+
ls -la doc_redaction/example_data/
|
| 103 |
+
echo "Checking for specific PDF files:"
|
| 104 |
+
ls -la doc_redaction/example_data/*.pdf || echo "No PDF files found"
|
| 105 |
+
echo "Checking file sizes:"
|
| 106 |
+
find doc_redaction/example_data -name "*.pdf" -exec ls -lh {} \;
|
| 107 |
+
|
| 108 |
+
- name: Clean up problematic config files
|
| 109 |
+
run: |
|
| 110 |
+
rm -f config*.py || true
|
| 111 |
+
|
| 112 |
+
- name: Run CLI tests
|
| 113 |
+
run: |
|
| 114 |
+
cd test
|
| 115 |
+
python cli_epilog_suite.py
|
| 116 |
+
|
| 117 |
+
- name: Run tests with pytest (JUnit and coverage)
|
| 118 |
+
run: |
|
| 119 |
+
pytest test/ -v --tb=short \
|
| 120 |
+
--junitxml=test-results.xml \
|
| 121 |
+
--cov=. --cov-config=.coveragerc \
|
| 122 |
+
--cov-report=xml --cov-report=html --cov-report=term
|
| 123 |
+
|
| 124 |
+
#- name: Upload coverage to Codecov - not necessary
|
| 125 |
+
# uses: codecov/codecov-action@v3
|
| 126 |
+
# if: matrix.python-version == '3.11'
|
| 127 |
+
# with:
|
| 128 |
+
# file: ./coverage.xml
|
| 129 |
+
# flags: unittests
|
| 130 |
+
# name: codecov-umbrella
|
| 131 |
+
# fail_ci_if_error: false
|
| 132 |
+
|
| 133 |
+
- name: Upload test results
|
| 134 |
+
uses: actions/upload-artifact@v6
|
| 135 |
+
if: always()
|
| 136 |
+
with:
|
| 137 |
+
name: test-results-python-${{ matrix.python-version }}
|
| 138 |
+
path: |
|
| 139 |
+
test-results.xml
|
| 140 |
+
htmlcov/
|
| 141 |
+
coverage.xml
|
| 142 |
+
|
| 143 |
+
test-integration:
|
| 144 |
+
runs-on: ubuntu-latest
|
| 145 |
+
needs: [lint, test-unit]
|
| 146 |
+
env:
|
| 147 |
+
SHOW_VLM_MODEL_OPTIONS: "False"
|
| 148 |
+
|
| 149 |
+
steps:
|
| 150 |
+
- uses: actions/checkout@v6
|
| 151 |
+
|
| 152 |
+
- name: Set up Python
|
| 153 |
+
uses: actions/setup-python@v6
|
| 154 |
+
with:
|
| 155 |
+
python-version: ${{ env.PYTHON_VERSION }}
|
| 156 |
+
|
| 157 |
+
- name: Install dependencies
|
| 158 |
+
run: |
|
| 159 |
+
python -m pip install --upgrade pip
|
| 160 |
+
pip install -r requirements_lightweight.txt
|
| 161 |
+
pip install pytest pytest-cov reportlab pillow
|
| 162 |
+
|
| 163 |
+
- name: Install system dependencies
|
| 164 |
+
run: |
|
| 165 |
+
sudo apt-get update
|
| 166 |
+
sudo apt-get install -y \
|
| 167 |
+
tesseract-ocr \
|
| 168 |
+
tesseract-ocr-eng \
|
| 169 |
+
poppler-utils \
|
| 170 |
+
libgl1-mesa-dri \
|
| 171 |
+
libglib2.0-0 \
|
| 172 |
+
libsm6 \
|
| 173 |
+
libxext6 \
|
| 174 |
+
libxrender-dev \
|
| 175 |
+
libgomp1
|
| 176 |
+
|
| 177 |
+
- name: Download spaCy model
|
| 178 |
+
run: |
|
| 179 |
+
python -m spacy download en_core_web_lg
|
| 180 |
+
|
| 181 |
+
- name: Setup test data
|
| 182 |
+
run: |
|
| 183 |
+
python .github/scripts/setup_test_data.py
|
| 184 |
+
echo "Setup script completed. Checking results:"
|
| 185 |
+
ls -la doc_redaction/example_data/ || echo "doc_redaction/example_data directory not found"
|
| 186 |
+
|
| 187 |
+
- name: Verify test data files
|
| 188 |
+
run: |
|
| 189 |
+
echo "Checking if critical test files exist:"
|
| 190 |
+
ls -la doc_redaction/example_data/
|
| 191 |
+
echo "Checking for specific PDF files:"
|
| 192 |
+
ls -la doc_redaction/example_data/*.pdf || echo "No PDF files found"
|
| 193 |
+
echo "Checking file sizes:"
|
| 194 |
+
find doc_redaction/example_data -name "*.pdf" -exec ls -lh {} \;
|
| 195 |
+
|
| 196 |
+
- name: Run integration tests
|
| 197 |
+
run: |
|
| 198 |
+
cd test
|
| 199 |
+
python demo_single_test.py
|
| 200 |
+
|
| 201 |
+
- name: Test CLI help
|
| 202 |
+
run: |
|
| 203 |
+
python cli_redact.py --help
|
| 204 |
+
|
| 205 |
+
- name: Test CLI version
|
| 206 |
+
run: |
|
| 207 |
+
python -c "import sys; print(f'Python {sys.version}')"
|
| 208 |
+
|
| 209 |
+
security:
|
| 210 |
+
runs-on: ubuntu-latest
|
| 211 |
+
steps:
|
| 212 |
+
- uses: actions/checkout@v6
|
| 213 |
+
|
| 214 |
+
- name: Set up Python
|
| 215 |
+
uses: actions/setup-python@v6
|
| 216 |
+
with:
|
| 217 |
+
python-version: ${{ env.PYTHON_VERSION }}
|
| 218 |
+
|
| 219 |
+
- name: Install dependencies
|
| 220 |
+
run: |
|
| 221 |
+
python -m pip install --upgrade pip
|
| 222 |
+
pip install safety bandit
|
| 223 |
+
|
| 224 |
+
#- name: Run safety scan - removed as now requires login
|
| 225 |
+
# run: |
|
| 226 |
+
# safety scan -r requirements.txt
|
| 227 |
+
|
| 228 |
+
- name: Run bandit security check
|
| 229 |
+
run: |
|
| 230 |
+
bandit -r . -f json -o bandit-report.json || true
|
| 231 |
+
|
| 232 |
+
- name: Upload security report
|
| 233 |
+
uses: actions/upload-artifact@v6
|
| 234 |
+
if: always()
|
| 235 |
+
with:
|
| 236 |
+
name: security-report
|
| 237 |
+
path: bandit-report.json
|
| 238 |
+
|
| 239 |
+
build:
|
| 240 |
+
runs-on: ubuntu-latest
|
| 241 |
+
needs: [lint, test-unit]
|
| 242 |
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
| 243 |
+
|
| 244 |
+
steps:
|
| 245 |
+
- uses: actions/checkout@v6
|
| 246 |
+
|
| 247 |
+
- name: Set up Python
|
| 248 |
+
uses: actions/setup-python@v6
|
| 249 |
+
with:
|
| 250 |
+
python-version: ${{ env.PYTHON_VERSION }}
|
| 251 |
+
|
| 252 |
+
- name: Install build dependencies
|
| 253 |
+
run: |
|
| 254 |
+
python -m pip install --upgrade pip
|
| 255 |
+
pip install build twine
|
| 256 |
+
|
| 257 |
+
- name: Build package
|
| 258 |
+
run: |
|
| 259 |
+
python -m build
|
| 260 |
+
|
| 261 |
+
- name: Check package
|
| 262 |
+
run: |
|
| 263 |
+
twine check dist/*
|
| 264 |
+
|
| 265 |
+
- name: Upload build artifacts
|
| 266 |
+
uses: actions/upload-artifact@v6
|
| 267 |
+
with:
|
| 268 |
+
name: dist
|
| 269 |
+
path: dist/
|
.github/workflows/simple-test.yml
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Simple Test Run
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ dev ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ dev ]
|
| 8 |
+
workflow_dispatch:
|
| 9 |
+
|
| 10 |
+
permissions:
|
| 11 |
+
contents: read
|
| 12 |
+
actions: read
|
| 13 |
+
|
| 14 |
+
jobs:
|
| 15 |
+
test:
|
| 16 |
+
runs-on: ubuntu-latest
|
| 17 |
+
env:
|
| 18 |
+
SHOW_VLM_MODEL_OPTIONS: "False"
|
| 19 |
+
|
| 20 |
+
steps:
|
| 21 |
+
- uses: actions/checkout@v6
|
| 22 |
+
|
| 23 |
+
- name: Set up Python 3.12
|
| 24 |
+
uses: actions/setup-python@v6
|
| 25 |
+
with:
|
| 26 |
+
python-version: "3.12"
|
| 27 |
+
|
| 28 |
+
- name: Install system dependencies
|
| 29 |
+
run: |
|
| 30 |
+
sudo apt-get update
|
| 31 |
+
sudo apt-get install -y \
|
| 32 |
+
tesseract-ocr \
|
| 33 |
+
tesseract-ocr-eng \
|
| 34 |
+
poppler-utils \
|
| 35 |
+
libgl1-mesa-dri \
|
| 36 |
+
libglib2.0-0 \
|
| 37 |
+
libsm6 \
|
| 38 |
+
libxext6 \
|
| 39 |
+
libxrender-dev \
|
| 40 |
+
libgomp1
|
| 41 |
+
|
| 42 |
+
- name: Install Python dependencies
|
| 43 |
+
run: |
|
| 44 |
+
python -m pip install --upgrade pip
|
| 45 |
+
pip install -r requirements_lightweight.txt
|
| 46 |
+
pip install pytest pytest-cov reportlab pillow
|
| 47 |
+
|
| 48 |
+
- name: Download spaCy model
|
| 49 |
+
run: |
|
| 50 |
+
python -m spacy download en_core_web_lg
|
| 51 |
+
|
| 52 |
+
- name: Setup test data
|
| 53 |
+
run: |
|
| 54 |
+
python .github/scripts/setup_test_data.py
|
| 55 |
+
echo "Setup script completed. Checking results:"
|
| 56 |
+
ls -la doc_redaction/example_data/ || echo "doc_redaction/example_data directory not found"
|
| 57 |
+
|
| 58 |
+
- name: Verify test data files
|
| 59 |
+
run: |
|
| 60 |
+
echo "Checking if critical test files exist:"
|
| 61 |
+
ls -la doc_redaction/example_data/
|
| 62 |
+
echo "Checking for specific PDF files:"
|
| 63 |
+
ls -la doc_redaction/example_data/*.pdf || echo "No PDF files found"
|
| 64 |
+
echo "Checking file sizes:"
|
| 65 |
+
find doc_redaction/example_data -name "*.pdf" -exec ls -lh {} \;
|
| 66 |
+
|
| 67 |
+
- name: Run CLI tests
|
| 68 |
+
run: |
|
| 69 |
+
cd test
|
| 70 |
+
python cli_epilog_suite.py
|
| 71 |
+
|
| 72 |
+
- name: Run tests with pytest
|
| 73 |
+
run: |
|
| 74 |
+
pytest test/ -v --tb=short
|
.github/workflows/sync-pi-agent-space.yml
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync Pi agent to Hugging Face Space
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [dev]
|
| 6 |
+
paths:
|
| 7 |
+
- "agent-redact/**"
|
| 8 |
+
- "skills/**"
|
| 9 |
+
- "tools/**"
|
| 10 |
+
- "intros/**"
|
| 11 |
+
- "doc_redaction/example_data/**"
|
| 12 |
+
- "AGENTS.md"
|
| 13 |
+
- "config/**"
|
| 14 |
+
- ".github/workflows/sync-pi-agent-space.yml"
|
| 15 |
+
workflow_dispatch:
|
| 16 |
+
|
| 17 |
+
permissions:
|
| 18 |
+
contents: read
|
| 19 |
+
|
| 20 |
+
jobs:
|
| 21 |
+
sync-pi-agent-space:
|
| 22 |
+
runs-on: ubuntu-latest
|
| 23 |
+
steps:
|
| 24 |
+
- uses: actions/checkout@v6
|
| 25 |
+
with:
|
| 26 |
+
fetch-depth: 1
|
| 27 |
+
lfs: true
|
| 28 |
+
|
| 29 |
+
- name: Install Git LFS
|
| 30 |
+
run: git lfs install
|
| 31 |
+
|
| 32 |
+
- name: Materialize example PDFs (Git LFS)
|
| 33 |
+
run: |
|
| 34 |
+
git lfs pull --include="doc_redaction/example_data/*.pdf"
|
| 35 |
+
for f in \
|
| 36 |
+
doc_redaction/example_data/example_of_emails_sent_to_a_professor_before_applying.pdf \
|
| 37 |
+
doc_redaction/example_data/graduate-job-example-cover-letter.pdf; do
|
| 38 |
+
if head -1 "$f" | grep -q "^version https://git-lfs.github.com/spec/v1"; then
|
| 39 |
+
echo "Example PDF is still an LFS pointer (not materialized): $f" >&2
|
| 40 |
+
exit 1
|
| 41 |
+
fi
|
| 42 |
+
done
|
| 43 |
+
|
| 44 |
+
- name: Flatten Pi agent Space tree
|
| 45 |
+
run: |
|
| 46 |
+
chmod +x agent-redact/pi-agent/sync_to_space.sh
|
| 47 |
+
agent-redact/pi-agent/sync_to_space.sh /tmp/pi-agent-space
|
| 48 |
+
|
| 49 |
+
- name: Push to Hugging Face Space
|
| 50 |
+
run: |
|
| 51 |
+
COMMIT_MSG=$(git log -1 --pretty=%B)
|
| 52 |
+
echo "Syncing Pi agent Space: seanpedrickcase/agentic_document_redaction"
|
| 53 |
+
cd /tmp/pi-agent-space
|
| 54 |
+
git init -b main
|
| 55 |
+
git config user.name "$HF_USERNAME"
|
| 56 |
+
git config user.email "$HF_EMAIL"
|
| 57 |
+
git add .
|
| 58 |
+
git commit -m "Sync Pi agent Space: $COMMIT_MSG"
|
| 59 |
+
git remote add hf "https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/spaces/${HF_USERNAME}/agentic_document_redaction"
|
| 60 |
+
git push --force hf main
|
| 61 |
+
env:
|
| 62 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 63 |
+
HF_USERNAME: ${{ secrets.HF_USERNAME }}
|
| 64 |
+
HF_EMAIL: ${{ secrets.HF_EMAIL }}
|
.github/workflows/sync_to_hf.yml
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face hub
|
| 2 |
+
on:
|
| 3 |
+
push:
|
| 4 |
+
branches: [dev]
|
| 5 |
+
workflow_dispatch:
|
| 6 |
+
|
| 7 |
+
permissions:
|
| 8 |
+
contents: read
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
sync-to-hub:
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
steps:
|
| 14 |
+
- uses: actions/checkout@v6
|
| 15 |
+
with:
|
| 16 |
+
fetch-depth: 1 # Only get the latest state
|
| 17 |
+
lfs: true # Download actual LFS files so they can be pushed
|
| 18 |
+
|
| 19 |
+
- name: Install Git LFS
|
| 20 |
+
run: git lfs install
|
| 21 |
+
|
| 22 |
+
- name: Recreate repo history (single-commit force push)
|
| 23 |
+
run: |
|
| 24 |
+
# 1. Capture the message BEFORE we delete the .git folder
|
| 25 |
+
COMMIT_MSG=$(git log -1 --pretty=%B)
|
| 26 |
+
echo "Syncing commit message: $COMMIT_MSG"
|
| 27 |
+
|
| 28 |
+
# 2. DELETE the .git folder.
|
| 29 |
+
# This turns the repo into a standard folder of files.
|
| 30 |
+
rm -rf .git
|
| 31 |
+
|
| 32 |
+
# 3. Re-initialize a brand new git repo
|
| 33 |
+
git init -b main
|
| 34 |
+
git config --global user.name "$HF_USERNAME"
|
| 35 |
+
git config --global user.email "$HF_EMAIL"
|
| 36 |
+
|
| 37 |
+
# 4. Re-install LFS (needs to be done after git init)
|
| 38 |
+
git lfs install
|
| 39 |
+
|
| 40 |
+
# 5. Add the remote
|
| 41 |
+
git remote add hf https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/$HF_USERNAME/$HF_REPO_ID
|
| 42 |
+
|
| 43 |
+
# 6. Add all files
|
| 44 |
+
# Since this is a fresh init, Git sees EVERY file as "New"
|
| 45 |
+
git add .
|
| 46 |
+
|
| 47 |
+
# 7. Commit and Force Push
|
| 48 |
+
git commit -m "Sync: $COMMIT_MSG"
|
| 49 |
+
git push --force hf main
|
| 50 |
+
env:
|
| 51 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 52 |
+
HF_USERNAME: ${{ secrets.HF_USERNAME }}
|
| 53 |
+
HF_EMAIL: ${{ secrets.HF_EMAIL }}
|
| 54 |
+
HF_REPO_ID: ${{ secrets.HF_REPO_ID }}
|
.github/workflows/sync_to_hf_zero_gpu.yml
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face hub Zero GPU
|
| 2 |
+
on:
|
| 3 |
+
push:
|
| 4 |
+
branches: [dev]
|
| 5 |
+
workflow_dispatch:
|
| 6 |
+
|
| 7 |
+
permissions:
|
| 8 |
+
contents: read
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
sync-to-hub-zero-gpu:
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
steps:
|
| 14 |
+
- uses: actions/checkout@v6
|
| 15 |
+
with:
|
| 16 |
+
fetch-depth: 1 # Only get the latest state
|
| 17 |
+
lfs: true # Download actual LFS files so they can be pushed
|
| 18 |
+
|
| 19 |
+
- name: Install Git LFS
|
| 20 |
+
run: git lfs install
|
| 21 |
+
|
| 22 |
+
# HF Spaces read Space config from README.md front matter. The repo README
|
| 23 |
+
# targets GitHub (e.g. docker); patch only this CI checkout before HF push.
|
| 24 |
+
- name: Apply HF Zero GPU Space README front matter
|
| 25 |
+
run: python3 tools/apply_hf_zero_gpu_readme_frontmatter.py
|
| 26 |
+
|
| 27 |
+
- name: Recreate repo history (single-commit force push)
|
| 28 |
+
run: |
|
| 29 |
+
# 1. Capture the message BEFORE we delete the .git folder
|
| 30 |
+
COMMIT_MSG=$(git log -1 --pretty=%B)
|
| 31 |
+
echo "Syncing commit message: $COMMIT_MSG"
|
| 32 |
+
|
| 33 |
+
# 2. DELETE the .git folder.
|
| 34 |
+
# This turns the repo into a standard folder of files.
|
| 35 |
+
rm -rf .git
|
| 36 |
+
|
| 37 |
+
# 3. Re-initialize a brand new git repo
|
| 38 |
+
git init -b main
|
| 39 |
+
git config --global user.name "$HF_USERNAME"
|
| 40 |
+
git config --global user.email "$HF_EMAIL"
|
| 41 |
+
|
| 42 |
+
# 4. Re-install LFS (needs to be done after git init)
|
| 43 |
+
git lfs install
|
| 44 |
+
|
| 45 |
+
# 5. Add the remote
|
| 46 |
+
git remote add hf https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/$HF_USERNAME/$HF_REPO_ID_ZERO_GPU
|
| 47 |
+
|
| 48 |
+
# 6. Add all files
|
| 49 |
+
# Since this is a fresh init, Git sees EVERY file as "New"
|
| 50 |
+
git add .
|
| 51 |
+
|
| 52 |
+
# 7. Commit and Force Push
|
| 53 |
+
git commit -m "Sync: $COMMIT_MSG"
|
| 54 |
+
git push --force hf main
|
| 55 |
+
env:
|
| 56 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 57 |
+
HF_USERNAME: ${{ secrets.HF_USERNAME }}
|
| 58 |
+
HF_EMAIL: ${{ secrets.HF_EMAIL }}
|
| 59 |
+
HF_REPO_ID_ZERO_GPU: ${{ secrets.HF_REPO_ID_ZERO_GPU }}
|
.gitignore
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.url
|
| 2 |
+
*.ipynb
|
| 3 |
+
*.pyc
|
| 4 |
+
*.qmd
|
| 5 |
+
*.json.bak.*
|
| 6 |
+
_quarto.yml
|
| 7 |
+
quarto_site/*
|
| 8 |
+
src/*
|
| 9 |
+
redaction_deps/*
|
| 10 |
+
.venv/*
|
| 11 |
+
examples/*
|
| 12 |
+
processing/*
|
| 13 |
+
input/*
|
| 14 |
+
output/*
|
| 15 |
+
tools/__pycache__/*
|
| 16 |
+
old_code/*
|
| 17 |
+
tesseract/*
|
| 18 |
+
poppler/*
|
| 19 |
+
build/*
|
| 20 |
+
dist/*
|
| 21 |
+
build_deps/*
|
| 22 |
+
logs/*
|
| 23 |
+
usage/*
|
| 24 |
+
feedback/*
|
| 25 |
+
config/*
|
| 26 |
+
!config/agent.env.example
|
| 27 |
+
!config/docker_app_config.env.example
|
| 28 |
+
!config/app_config.env.example
|
| 29 |
+
workspace/*
|
| 30 |
+
user_guide/*
|
| 31 |
+
_extensions/*
|
| 32 |
+
doc_redaction.egg-info/*
|
| 33 |
+
.venv_pypi_test/*
|
| 34 |
+
cdk/config/*
|
| 35 |
+
!cdk/config/app_config.env.example
|
| 36 |
+
!cdk/config/lambda/
|
| 37 |
+
cdk/config/lambda/*
|
| 38 |
+
!cdk/config/lambda/lambda_function.py
|
| 39 |
+
!cdk/config/headless_s3_seed/
|
| 40 |
+
cdk/config/headless_s3_seed/*
|
| 41 |
+
!cdk/config/headless_s3_seed/input/
|
| 42 |
+
cdk/config/headless_s3_seed/input/*
|
| 43 |
+
!cdk/config/headless_s3_seed/input/config/
|
| 44 |
+
cdk/config/headless_s3_seed/input/config/*
|
| 45 |
+
!cdk/config/headless_s3_seed/input/config/example_headless_env_file.env
|
| 46 |
+
cdk/cdk.out/*
|
| 47 |
+
cdk/archive/*
|
| 48 |
+
tld/*
|
| 49 |
+
tmp/*
|
| 50 |
+
docs/*
|
| 51 |
+
.pi/*
|
| 52 |
+
cdk.out/*
|
| 53 |
+
cdk.json
|
| 54 |
+
cdk.context.json
|
| 55 |
+
precheck.context.json
|
| 56 |
+
.quarto/*
|
| 57 |
+
/.quarto/
|
| 58 |
+
/_site/
|
| 59 |
+
test/config/*
|
| 60 |
+
test/feedback/*
|
| 61 |
+
test/input/*
|
| 62 |
+
test/logs/*
|
| 63 |
+
test/output/*
|
| 64 |
+
test/tmp/*
|
| 65 |
+
test/usage/*
|
| 66 |
+
.ruff_cache/*
|
| 67 |
+
model_cache/*
|
| 68 |
+
sanitized_file/*
|
| 69 |
+
src/doc_redaction.egg-info/*
|
| 70 |
+
docker_compose/*
|
| 71 |
+
**/*.quarto_ipynb
|
| 72 |
+
skills/example_prompts/*
|
| 73 |
+
.pi/sessions/
|
| 74 |
+
agent-redact/pi/agent/sessions/
|
| 75 |
+
agent-redact/RedactionAgent/*
|
AGENTS.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AGENTS.md
|
| 2 |
+
|
| 3 |
+
Context for AI coding agents working on **doc_redaction** (PII redaction for PDFs, images, Word, and tabular files). Human-oriented docs: [README.md](README.md). User guide: [doc_redaction user guide](https://seanpedrick-case.github.io/doc_redaction/src/user_guide.html).
|
| 4 |
+
|
| 5 |
+
## Project overview
|
| 6 |
+
|
| 7 |
+
- **Stack**: Python 3.10+, Gradio UI ([app.py](app.py)), optional FastAPI when `RUN_FASTAPI` is enabled, AWS/LLM integrations via [tools/config.py](tools/config.py) and env files under `config/`.
|
| 8 |
+
- **License**: AGPL-3.0-only (see [pyproject.toml](pyproject.toml)). Respect license terms when adding dependencies.
|
| 9 |
+
- **Accuracy**: Outputs are not guaranteed complete; downstream use should assume **human review** of redacted material.
|
| 10 |
+
|
| 11 |
+
## Cursor skills: redaction workflow (optional)
|
| 12 |
+
|
| 13 |
+
For agents operating the deployed app (Gradio Client, review CSV, `/review_apply`), these repo-local playbooks are a suggested ladder:
|
| 14 |
+
|
| 15 |
+
0. **[`skills/doc-redaction-task-prompt/TASK_PROMPT_TEMPLATE.md`](skills/doc-redaction-task-prompt/TASK_PROMPT_TEMPLATE.md)** β copy-paste user task prompt (Pass 1 default, Pass 2 gated); **user redaction requirements go at the end of the prompt**.
|
| 16 |
+
1. **[`skills/doc-redaction-app/SKILL.md`](skills/doc-redaction-app/SKILL.md)** β first-pass redaction (`/doc_redact` / `/redact_document`) and downloading artifacts.
|
| 17 |
+
2. **[`skills/doc-redact-page-review/SKILL.md`](skills/doc-redact-page-review/SKILL.md)** β after outputs exist: **parallel per-page** child agents, merge into one full-document `*_review_file.csv`, **single** `/review_apply` from the parent.
|
| 18 |
+
3. **[`skills/doc-redaction-modifications/SKILL.md`](skills/doc-redaction-modifications/SKILL.md)** β CSV mechanics, `preview_redaction_boxes`, `/review_apply` patterns, verification, VLM and PyMuPDF fallbacks (single-thread edits and the **technical** reference for page-review children).
|
| 19 |
+
|
| 20 |
+
## Setup
|
| 21 |
+
|
| 22 |
+
1. **System**: Install **Tesseract** and **Poppler** (required for OCR/PDF). See [README.md](README.md) (Windows/Linux sections).
|
| 23 |
+
2. **Python**: Create a venv, then install the project (e.g. `pip install -e ".[dev]"` or follow README).
|
| 24 |
+
3. **Configuration**: Copy or edit environment/config as described in README / `config/` (e.g. `app_config.env`). Do not commit secrets.
|
| 25 |
+
|
| 26 |
+
## Run locally
|
| 27 |
+
|
| 28 |
+
- Gradio/FastAPI entrypoint is [app.py](app.py). With FastAPI enabled, typical pattern is `uvicorn app:app --host 0.0.0.0 --port 7860` (exact host/port from your config).
|
| 29 |
+
- OpenAPI docs: `/docs` when the FastAPI app is mounted.
|
| 30 |
+
|
| 31 |
+
## Tests
|
| 32 |
+
|
| 33 |
+
- Run from repo root: `pytest` (optional: `pytest test/`).
|
| 34 |
+
- Fix failures related to your changes before opening a PR.
|
| 35 |
+
|
| 36 |
+
## Line order (local OCR and simple text extraction)
|
| 37 |
+
|
| 38 |
+
Multi-column layouts use shared logic in [`tools/ocr_reading_order.py`](tools/ocr_reading_order.py). Controlled by **`LOCAL_OCR_READING_ORDER`** (`column` default, `legacy` for previous top-left behaviour).
|
| 39 |
+
|
| 40 |
+
### Local OCR (Paddle/Tesseract)
|
| 41 |
+
|
| 42 |
+
Word boxes are merged into line-level CSV rows in [`combine_ocr_results`](tools/custom_image_analyser_engine.py).
|
| 43 |
+
|
| 44 |
+
- **`column`**: detect text columns, assign line numbers down each column left-to-right; full-width lines (headers) first. Stops cross-column merging that produced wide erroneous lines on multi-column PDFs. **Auto-fallback**: the page is treated as single-column unless a *consecutive cluster* of gutter rows (y-gap between adjacent rows β€ `OCR_COLUMN_MAX_CONSECUTIVE_GUTTER_GAP`, default `0.06` of page height) has β₯ `OCR_COLUMN_MIN_GUTTER_ROWS` (default `3`) rows **and** the cluster's topmost row is above the footer zone (`OCR_COLUMN_FOOTER_ZONE_FRACTION`, default `0.75`). This prevents isolated header bands (logo | title, 1 gutter row), signature-only blocks at the page bottom (cluster starts at y β₯ 0.75), or the combination of both, from forcing column mode on the single-column body text between them.
|
| 45 |
+
- **`PADDLE_PRESERVE_LINE_BOXES=True`** or **`CONVERT_LINE_TO_WORD_LEVEL=False`** with Paddle: keep Paddle line boxes (skip word split + regrouping); line numbers still use column reading order.
|
| 46 |
+
|
| 47 |
+
### Simple text extraction (PyMuPDF)
|
| 48 |
+
|
| 49 |
+
[`redact_text_pdf`](tools/file_redaction.py) β [`process_page_to_structured_ocr_pymupdf`](tools/file_redaction.py) calls [`reorder_structured_text_lines`](tools/ocr_reading_order.py) after collecting lines, using **`page.mediabox`** width/height for full-span header detection.
|
| 50 |
+
|
| 51 |
+
`reorder_structured_text_lines` now mirrors `build_line_groups` (local OCR route):
|
| 52 |
+
|
| 53 |
+
1. **Column-aware sort** (`sort_reading_order` / `assign_layout_boxes` / `detect_column_split_xpoints`) β or legacy top-left for single-column pages.
|
| 54 |
+
2. **Y-band grouping** (`group_into_lines`) β merges any same-row PyMuPDF lines that were emitted as separate objects (e.g. mixed-font spans) and splits horizontally-disparate boxes via `_finalize_line`. *Column mode only.*
|
| 55 |
+
3. **Secondary sub-column pass** (`_reorder_lines_column_major`) β ensures correct column-major order when sub-columns sit within a single macro-column. *Column mode only.*
|
| 56 |
+
4. When a group contains more than one box, constituent boxes are **merged** into a single `OCRResult` (union bbox, joined text, concatenated chars/words).
|
| 57 |
+
|
| 58 |
+
In single-column / legacy mode only step 1 is applied; PyMuPDF lines are pre-formed so no merging is needed.
|
| 59 |
+
|
| 60 |
+
### Tunables (both routes)
|
| 61 |
+
|
| 62 |
+
`OCR_FULL_SPAN_WIDTH_RATIO`, `OCR_COLUMN_GAP_MIN_FRACTION`, `OCR_COLUMN_GUTTER_MIN_FRACTION`, `OCR_COLUMN_SUBGUTTER_MIN_FRACTION` (default `0.015` β fine-grained gutter scan in `assign_layout_boxes`; lower = detects narrower sub-column boundaries), `OCR_COLUMN_MIN_GUTTER_ROWS`, `OCR_COLUMN_MAX_BOX_HEIGHT_RATIO`, `OCR_COLUMN_MAX_CONSECUTIVE_GUTTER_GAP`, `OCR_COLUMN_FOOTER_ZONE_FRACTION`, `OCR_LINE_SPLIT_GAP_FRACTION` (default 0.025 β horizontal gap fraction that forces a line split; must be below the narrowest column gutter, ~0.030 for two-page spreads; also used as the gap threshold for the secondary sub-column sort in `build_line_groups`), `OCR_LINE_Y_THRESHOLD_FRACTION` (default 0.013 β row-alignment tolerance as a fraction of page height; reduced from 0.015 to correctly separate tightly-set 10 pt body text whose row spacing is ~0.014), `OCR_LINE_Y_THRESHOLD_MIN_PX`.
|
| 63 |
+
|
| 64 |
+
**Sub-column ordering** (`build_line_groups`): after the primary word-level column sort, a second pass (`_reorder_lines_column_major`) clusters the produced line groups by their leftmost x-position using `OCR_LINE_SPLIT_GAP_FRACTION` as the gap threshold. This ensures that adjacent narrow sub-columns whose word-level centre gap is below `column_gap_threshold` (e.g. two columns on a spread where each page is already one macro-column) are still output in left-to-right column-major order rather than interleaved by y-position.
|
| 65 |
+
|
| 66 |
+
**Fine-grained gutter-based column assignment** (`assign_layout_boxes`): before falling back to centre-gap clustering, `detect_column_split_xpoints` scans the page for structural gutters at the finer `OCR_COLUMN_SUBGUTTER_MIN_FRACTION` threshold (default 0.015). Each qualifying gutter cluster produces a `(split_x, y_min)` pair β the split point is only applied to boxes whose `top β₯ y_min`, preventing a narrow sub-column gutter (visible only in the lower two-column section) from mis-splitting a full-width introductory paragraph that sits above it. This correctly separates narrow adjacent columns (e.g. 1.9 % gutter on a two-page spread) without fragmenting full-width headings or paragraphs.
|
| 67 |
+
|
| 68 |
+
Changing line order affects PII page text, duplicate-page detection, and review CSV line indices on multi-column documents; re-review after upgrading.
|
| 69 |
+
|
| 70 |
+
## Agentic / programmatic access (two surfaces)
|
| 71 |
+
|
| 72 |
+
### 1. FastAPI Agent API (recommended for LLM agents: small JSON bodies)
|
| 73 |
+
|
| 74 |
+
When `RUN_FASTAPI` is true, routes are mounted under **`/agent`** ([agent_routes.py](agent_routes.py)).
|
| 75 |
+
|
| 76 |
+
- **Catalog**: `GET /agent/operations` β maps each Gradio `api_name` to an HTTP path and notes whether the route is implemented via CLI or returns HTTP 501 for Gradio-only flows.
|
| 77 |
+
- **Implemented POST routes** (CLI- or [tools/simplified_api.py](tools/simplified_api.py)-backed where noted):
|
| 78 |
+
`redact_document`, `redact_data`, `find_duplicate_pages`, `find_duplicate_tabular`, `summarise_document`, `combine_review_pdfs`, `combine_review_csvs`, `export_review_redaction_overlay`, `export_review_page_ocr_visualisation`, `apply_review_redactions`, **`verify_redaction_coverage`** (Pass 1 QA: `must_redact` / `must_not_redact` regex lists, optional `redacted_pdf_path`, optional `auto_prune_suspicious` + `pruned_output_path`; returns `pass_strict`, `pass_with_cleanup`, `pages_flagged_for_vlm`, `pages_needing_csv_cleanup`), **`word_level_ocr_text_search`** (headless word OCR search with optional review-box overlap flags).
|
| 79 |
+
|
| 80 |
+
**Optional post-redaction Pass 1 QA (main app / CLI):** When `POST_REDACT_PASS1_QA=True` in [`tools/config.py`](tools/config.py) (or `config/app_config.env`), initial redaction emits `*_coverage_report.json` beside the review CSV and optionally `*_review_file_pruned.csv` (sibling, when `POST_REDACT_PASS1_AUTO_PRUNE=True`). Uses deny/allow lists and/or `POST_REDACT_PASS1_MUST_REDACT_PATH` / `POST_REDACT_PASS1_MUST_NOT_REDACT_PATH`. CLI overrides: `--post-redact-pass1-qa`, `--post-redact-pass1-auto-prune`. This is pre-review-apply sanity QA only β agent Pass 1 (policy edits + `/review_apply`) remains separate.
|
| 81 |
+
Note: on Gradio ([app.py](app.py)), the Review-tab visual exports use `api_name` **`page_redaction_review_image`** and **`page_ocr_review_image`**; the **`/agent`** routes above keep the explicit `export_review_*` names for the same operations.
|
| 82 |
+
- **Gradio-only stubs** (501 + JSON hint): `load_and_prepare_documents_or_data`.
|
| 83 |
+
- **Auth**: If `AGENT_API_KEY` is set in the environment, send header `X-Agent-API-Key` with that value.
|
| 84 |
+
- **Paths**: Inputs must resolve to files under the repo root, `INPUT_FOLDER`, or `OUTPUT_FOLDER` (see router validation).
|
| 85 |
+
|
| 86 |
+
Implementation uses **`cli_redact.main(direct_mode_args=...)`** where a CLI task exists (same behaviour as [cli_redact.py](cli_redact.py)); `apply_review_redactions` calls [tools/simplified_api.py](tools/simplified_api.py) instead.
|
| 87 |
+
|
| 88 |
+
### 2. Gradio Client API (e.g. Hugging Face Spaces)
|
| 89 |
+
|
| 90 |
+
For remote Spaces or any Gradio deployment exposing the HTTP API:
|
| 91 |
+
|
| 92 |
+
- **Schema**: `GET https://<host>/gradio_api/info`
|
| 93 |
+
- **Call**: `POST https://<host>/gradio_api/call/{api_name}` with body `{"data":[...]}` (argument order matches the named endpointβs component list).
|
| 94 |
+
- **Poll**: `GET https://<host>/gradio_api/call/{api_name}/{event_id}`
|
| 95 |
+
- **Hugging Face**: `Authorization: Bearer $HF_TOKEN`
|
| 96 |
+
|
| 97 |
+
Named `api_name` values in this app include: `redact_document`, `load_and_prepare_documents_or_data`, `apply_review_redactions`, **`doc_redact`** (simple `gr.api`: one PDF/image + optional OCR/PII knobs; returns `(output_paths, message)`; `api_name='/doc_redact'`; parameters include `document_file`, `redact_entities`, `output_dir`, `ocr_method`, `pii_method`, `allow_list`, `deny_list`, `page_min`, `page_max`, **`handwrite_signature_checkbox`** β AWS Textract extraction options such as `Extract handwriting` / `Extract signatures`), **`review_apply`** (simple `gr.api`: PDF + `*_review_file.csv`; returns `(output_paths, message)`; `api_name='/review_apply'`), **`preview_boxes`** (simple `gr.api`: PDF + `*_review_file.csv`; renders proposed boxes onto the original PDF and returns `(zip_path, message)` β use to verify coordinates *before* calling `review_apply`, no redaction applied; `api_name='/preview_boxes'`), **`pdf_summarise`** (simple `gr.api`: PDF + optional summarisation/OCR knobs; returns `(output_paths, status_message, summary_text)`; `api_name='/pdf_summarise'`), **`tabular_redact`** (simple `gr.api`: one tabular file (CSV/XLSX/Parquet/DOCX) + optional knobs; returns `(output_paths, message)`; `api_name='/tabular_redact'`), **`page_redaction_review_image`** (short review overlay export; `api_name='/page_redaction_review_image'`), **`page_ocr_review_image`** (short OCR visualisation export; `api_name='/page_ocr_review_image'`), `word_level_ocr_text_search`, `redact_data`, `find_duplicate_pages`, `find_duplicate_tabular`, `summarise_document`, `combine_review_csvs`, `combine_review_pdfs`. The matching **`POST /agent`** names for those two visual exports are `export_review_redaction_overlay` and `export_review_page_ocr_visualisation` (Β§1). Many endpoints require **many positional arguments** (full Gradio state); prefer the short `gr.api` routes above or **`POST /agent/apply_review_redactions`** where applicable instead of building the full `data` array from `/gradio_api/info`.
|
| 98 |
+
|
| 99 |
+
## CLI parity
|
| 100 |
+
|
| 101 |
+
For scripting and tests, `python cli_redact.py` with flags is authoritative; programmatic merges use `get_cli_default_args_dict()` in [cli_redact.py](cli_redact.py).
|
| 102 |
+
|
| 103 |
+
## Security and data handling
|
| 104 |
+
|
| 105 |
+
- Do not commit API keys, tokens, or customer data.
|
| 106 |
+
- Treat paths as untrusted outside validated roots (see [tools/secure_path_utils.py](tools/secure_path_utils.py)).
|
| 107 |
+
- Optional `instruction` / LLM fields must not be passed into shell or unconstrained config keys.
|
| 108 |
+
|
| 109 |
+
## Conventions for PRs
|
| 110 |
+
|
| 111 |
+
- Keep changes focused; avoid drive-by refactors.
|
| 112 |
+
- Match existing naming and patterns in [app.py](app.py) and [tools/](tools/).
|
| 113 |
+
- Update tests when behaviour changes; run `pytest` before merge.
|
Dockerfile
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: Build dependencies and download models
|
| 2 |
+
FROM public.ecr.aws/docker/library/python:3.12.13-slim-trixie AS builder
|
| 3 |
+
|
| 4 |
+
# Install system dependencies
|
| 5 |
+
RUN apt-get update \
|
| 6 |
+
&& apt-get upgrade -y \
|
| 7 |
+
&& apt-get install -y --no-install-recommends \
|
| 8 |
+
g++ \
|
| 9 |
+
make \
|
| 10 |
+
cmake \
|
| 11 |
+
unzip \
|
| 12 |
+
libcurl4-openssl-dev \
|
| 13 |
+
git \
|
| 14 |
+
&& pip install --upgrade pip \
|
| 15 |
+
&& apt-get clean \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
WORKDIR /src
|
| 19 |
+
|
| 20 |
+
COPY requirements_lightweight.txt .
|
| 21 |
+
|
| 22 |
+
RUN pip install --verbose --no-cache-dir --target=/install -r requirements_lightweight.txt && rm requirements_lightweight.txt
|
| 23 |
+
|
| 24 |
+
ARG INSTALL_GRADIO_MCP=False
|
| 25 |
+
ENV INSTALL_GRADIO_MCP=${INSTALL_GRADIO_MCP}
|
| 26 |
+
|
| 27 |
+
RUN if [ "$INSTALL_GRADIO_MCP" = "True" ]; then \
|
| 28 |
+
pip install --verbose --no-cache-dir --force-reinstall --target=/install "gradio[mcp]>=6.16.0"; \
|
| 29 |
+
fi
|
| 30 |
+
|
| 31 |
+
# Optionally install PaddleOCR if the INSTALL_PADDLEOCR environment variable is set to True. Note that GPU-enabled PaddleOCR is unlikely to work in the same environment as a GPU-enabled version of PyTorch, so it is recommended to install PaddleOCR as a CPU-only version if you want to use GPU-enabled PyTorch.
|
| 32 |
+
|
| 33 |
+
ARG INSTALL_PADDLEOCR=False
|
| 34 |
+
ENV INSTALL_PADDLEOCR=${INSTALL_PADDLEOCR}
|
| 35 |
+
|
| 36 |
+
ARG PADDLE_GPU_ENABLED=False
|
| 37 |
+
ENV PADDLE_GPU_ENABLED=${PADDLE_GPU_ENABLED}
|
| 38 |
+
|
| 39 |
+
RUN if [ "$INSTALL_PADDLEOCR" = "True" ] && [ "$PADDLE_GPU_ENABLED" = "False" ]; then \
|
| 40 |
+
pip install --verbose --no-cache-dir --target=/install "protobuf<=7.34.0" && \
|
| 41 |
+
pip install --verbose --no-cache-dir --target=/install "paddlepaddle<=3.2.1" && \
|
| 42 |
+
pip install --verbose --no-cache-dir --target=/install "paddleocr<=3.7.0"; \
|
| 43 |
+
elif [ "$INSTALL_PADDLEOCR" = "True" ] && [ "$PADDLE_GPU_ENABLED" = "True" ]; then \
|
| 44 |
+
pip install --verbose --no-cache-dir --target=/install "protobuf<=7.34.0" && \
|
| 45 |
+
pip install --verbose --no-cache-dir --target=/install "paddlepaddle<=3.2.1" && \
|
| 46 |
+
pip install --verbose --no-cache-dir --target=/install "paddleocr<=3.7.0" && \
|
| 47 |
+
pip install --verbose --no-cache-dir --target=/install "torch<=2.10.0" --index-url https://download.pytorch.org/whl/cu129 && \
|
| 48 |
+
pip install --verbose --no-cache-dir --target=/install "torchvision<=0.25.0" --index-url https://download.pytorch.org/whl/cu129 && \
|
| 49 |
+
pip install --verbose --no-cache-dir --target=/install "transformers<=5.12.0"; \
|
| 50 |
+
fi
|
| 51 |
+
|
| 52 |
+
ARG INSTALL_VLM=False
|
| 53 |
+
ENV INSTALL_VLM=${INSTALL_VLM}
|
| 54 |
+
|
| 55 |
+
ARG TORCH_GPU_ENABLED=False
|
| 56 |
+
ENV TORCH_GPU_ENABLED=${TORCH_GPU_ENABLED}
|
| 57 |
+
|
| 58 |
+
# Optionally install VLM/LLM packages if the INSTALL_VLM environment variable is set to True.
|
| 59 |
+
RUN if [ "$INSTALL_VLM" = "True" ] && [ "$TORCH_GPU_ENABLED" = "False" ]; then \
|
| 60 |
+
pip install --verbose --no-cache-dir --target=/install \
|
| 61 |
+
"torch==2.10.0+cpu" \
|
| 62 |
+
"torchvision==0.25.0+cpu" \
|
| 63 |
+
"transformers<=5.12.0" \
|
| 64 |
+
"accelerate<=1.13.0" \
|
| 65 |
+
"bitsandbytes<=0.49.2" \
|
| 66 |
+
"sentencepiece<=0.2.1" \
|
| 67 |
+
--extra-index-url https://download.pytorch.org/whl/cpu; \
|
| 68 |
+
elif [ "$INSTALL_VLM" = "True" ] && [ "$TORCH_GPU_ENABLED" = "True" ]; then \
|
| 69 |
+
pip install --verbose --no-cache-dir --target=/install "torch<=2.10.0" --index-url https://download.pytorch.org/whl/cu129 && \
|
| 70 |
+
pip install --verbose --no-cache-dir --target=/install "torchvision<=0.25.0" --index-url https://download.pytorch.org/whl/cu129 && \
|
| 71 |
+
pip install --verbose --no-cache-dir --target=/install \
|
| 72 |
+
"transformers<=5.12.0" \
|
| 73 |
+
"accelerate<=1.13.0" \
|
| 74 |
+
"bitsandbytes<=0.49.2" \
|
| 75 |
+
"sentencepiece<=0.2.1" && \
|
| 76 |
+
pip install --verbose --no-cache-dir --target=/install "optimum<=2.1.0" && \
|
| 77 |
+
pip install --verbose --no-cache-dir --target=/install https://github.com/Dao-AILab/flash-attention/releases/download/v2.8.3/flash_attn-2.8.3+cu12torch2.8cxx11abiTRUE-cp312-cp312-linux_x86_64.whl && \
|
| 78 |
+
pip install --verbose --no-cache-dir --target=/install https://github.com/ModelCloud/GPTQModel/releases/download/v5.8.0/gptqmodel-5.8.0+cu128torch2.8-cp312-cp312-linux_x86_64.whl; \
|
| 79 |
+
fi
|
| 80 |
+
|
| 81 |
+
# ===================================================================
|
| 82 |
+
# Stage 2: A common base for both Lambda and Gradio
|
| 83 |
+
# ===================================================================
|
| 84 |
+
FROM public.ecr.aws/docker/library/python:3.12.13-slim-trixie AS base
|
| 85 |
+
|
| 86 |
+
# MUST re-declare ARGs in every stage where they are used in RUN commands
|
| 87 |
+
ARG TORCH_GPU_ENABLED=False
|
| 88 |
+
ARG PADDLE_GPU_ENABLED=False
|
| 89 |
+
|
| 90 |
+
ENV TORCH_GPU_ENABLED=${TORCH_GPU_ENABLED}
|
| 91 |
+
ENV PADDLE_GPU_ENABLED=${PADDLE_GPU_ENABLED}
|
| 92 |
+
|
| 93 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 94 |
+
tesseract-ocr \
|
| 95 |
+
poppler-utils \
|
| 96 |
+
libgl1 \
|
| 97 |
+
libglib2.0-0 && \
|
| 98 |
+
if [ "$TORCH_GPU_ENABLED" = "True" ] || [ "$PADDLE_GPU_ENABLED" = "True" ]; then \
|
| 99 |
+
apt-get install -y --no-install-recommends libgomp1; \
|
| 100 |
+
fi && \
|
| 101 |
+
apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 102 |
+
|
| 103 |
+
ENV APP_HOME=/home/user
|
| 104 |
+
|
| 105 |
+
# Set env variables for Gradio & other apps
|
| 106 |
+
ENV GRADIO_TEMP_DIR=/tmp/gradio_tmp/ \
|
| 107 |
+
MPLCONFIGDIR=/tmp/matplotlib_cache/ \
|
| 108 |
+
GRADIO_OUTPUT_FOLDER=$APP_HOME/app/output/ \
|
| 109 |
+
GRADIO_INPUT_FOLDER=$APP_HOME/app/input/ \
|
| 110 |
+
FEEDBACK_LOGS_FOLDER=$APP_HOME/app/feedback/ \
|
| 111 |
+
ACCESS_LOGS_FOLDER=$APP_HOME/app/logs/ \
|
| 112 |
+
USAGE_LOGS_FOLDER=$APP_HOME/app/usage/ \
|
| 113 |
+
CONFIG_FOLDER=$APP_HOME/app/config/ \
|
| 114 |
+
XDG_CACHE_HOME=/tmp/xdg_cache/user_1000 \
|
| 115 |
+
TESSERACT_DATA_FOLDER=/usr/share/tessdata \
|
| 116 |
+
GRADIO_SERVER_NAME=0.0.0.0 \
|
| 117 |
+
GRADIO_SERVER_PORT=7860 \
|
| 118 |
+
PATH=$APP_HOME/.local/bin:$PATH \
|
| 119 |
+
PYTHONPATH=$APP_HOME/app \
|
| 120 |
+
PYTHONUNBUFFERED=1 \
|
| 121 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 122 |
+
GRADIO_ALLOW_FLAGGING=never \
|
| 123 |
+
GRADIO_NUM_PORTS=1 \
|
| 124 |
+
GRADIO_ANALYTICS_ENABLED=False
|
| 125 |
+
|
| 126 |
+
# Copy Python packages from the builder stage
|
| 127 |
+
COPY --from=builder /install /usr/local/lib/python3.12/site-packages/
|
| 128 |
+
COPY --from=builder /install/bin /usr/local/bin/
|
| 129 |
+
|
| 130 |
+
# Reinstall protobuf into the final site-packages. Builder uses multiple `pip install --target=/install`
|
| 131 |
+
# passes; that can break the `google` namespace so `google.protobuf` is missing and Paddle fails at import.
|
| 132 |
+
RUN pip install --no-cache-dir "protobuf<=7.34.0"
|
| 133 |
+
|
| 134 |
+
# English pipeline is not a normal PyPI dependency; bundle it in the image so runtime works offline.
|
| 135 |
+
# Placed before COPY app code so application changes do not invalidate this layer.
|
| 136 |
+
RUN python -m spacy download en_core_web_lg
|
| 137 |
+
|
| 138 |
+
# Copy your application code and entrypoint
|
| 139 |
+
COPY . ${APP_HOME}/app
|
| 140 |
+
COPY entrypoint.sh ${APP_HOME}/app/entrypoint.sh
|
| 141 |
+
# Fix line endings and set execute permissions
|
| 142 |
+
RUN sed -i 's/\r$//' ${APP_HOME}/app/entrypoint.sh \
|
| 143 |
+
&& chmod +x ${APP_HOME}/app/entrypoint.sh
|
| 144 |
+
|
| 145 |
+
WORKDIR ${APP_HOME}/app
|
| 146 |
+
|
| 147 |
+
# ===================================================================
|
| 148 |
+
# FINAL Stage 3: The Lambda Image (runs as root for simplicity)
|
| 149 |
+
# ===================================================================
|
| 150 |
+
FROM base AS lambda
|
| 151 |
+
# Set runtime ENV for Lambda mode
|
| 152 |
+
ENV APP_MODE=lambda
|
| 153 |
+
ENTRYPOINT ["/home/user/app/entrypoint.sh"]
|
| 154 |
+
CMD ["lambda_entrypoint.lambda_handler"]
|
| 155 |
+
|
| 156 |
+
# ===================================================================
|
| 157 |
+
# FINAL Stage 4: The Gradio Image (runs as a secure, non-root user)
|
| 158 |
+
# ===================================================================
|
| 159 |
+
FROM base AS gradio
|
| 160 |
+
# Set runtime ENV for Gradio mode
|
| 161 |
+
ENV APP_MODE=gradio
|
| 162 |
+
|
| 163 |
+
# Create non-root user
|
| 164 |
+
RUN useradd -m -u 1000 user
|
| 165 |
+
|
| 166 |
+
# Create the base application directory and set its ownership
|
| 167 |
+
RUN mkdir -p ${APP_HOME}/app && chown user:user ${APP_HOME}/app
|
| 168 |
+
|
| 169 |
+
# Create required sub-folders within the app directory and set their permissions
|
| 170 |
+
# This ensures these specific directories are owned by 'user'
|
| 171 |
+
RUN mkdir -p \
|
| 172 |
+
${APP_HOME}/app/output \
|
| 173 |
+
${APP_HOME}/app/input \
|
| 174 |
+
${APP_HOME}/app/logs \
|
| 175 |
+
${APP_HOME}/app/usage \
|
| 176 |
+
${APP_HOME}/app/feedback \
|
| 177 |
+
${APP_HOME}/app/config \
|
| 178 |
+
&& chown user:user \
|
| 179 |
+
${APP_HOME}/app/output \
|
| 180 |
+
${APP_HOME}/app/input \
|
| 181 |
+
${APP_HOME}/app/logs \
|
| 182 |
+
${APP_HOME}/app/usage \
|
| 183 |
+
${APP_HOME}/app/feedback \
|
| 184 |
+
${APP_HOME}/app/config \
|
| 185 |
+
&& chmod 755 \
|
| 186 |
+
${APP_HOME}/app/output \
|
| 187 |
+
${APP_HOME}/app/input \
|
| 188 |
+
${APP_HOME}/app/logs \
|
| 189 |
+
${APP_HOME}/app/usage \
|
| 190 |
+
${APP_HOME}/app/feedback \
|
| 191 |
+
${APP_HOME}/app/config
|
| 192 |
+
|
| 193 |
+
# Now handle the /tmp and /var/tmp directories and their subdirectories, paddle, spacy, tessdata
|
| 194 |
+
RUN mkdir -p /tmp/gradio_tmp /tmp/tld /tmp/matplotlib_cache /tmp /var/tmp ${XDG_CACHE_HOME} \
|
| 195 |
+
&& chown user:user /tmp /var/tmp /tmp/gradio_tmp /tmp/tld /tmp/matplotlib_cache ${XDG_CACHE_HOME} \
|
| 196 |
+
&& chmod 1777 /tmp /var/tmp /tmp/gradio_tmp /tmp/tld /tmp/matplotlib_cache \
|
| 197 |
+
&& chmod 700 ${XDG_CACHE_HOME} \
|
| 198 |
+
&& mkdir -p ${APP_HOME}/.paddlex \
|
| 199 |
+
&& chown user:user ${APP_HOME}/.paddlex \
|
| 200 |
+
&& chmod 755 ${APP_HOME}/.paddlex \
|
| 201 |
+
&& mkdir -p ${APP_HOME}/.local/share/spacy/data \
|
| 202 |
+
&& chown user:user ${APP_HOME}/.local/share/spacy/data \
|
| 203 |
+
&& chmod 755 ${APP_HOME}/.local/share/spacy/data \
|
| 204 |
+
&& mkdir -p /usr/share/tessdata \
|
| 205 |
+
&& chown user:user /usr/share/tessdata \
|
| 206 |
+
&& chmod 755 /usr/share/tessdata
|
| 207 |
+
|
| 208 |
+
# Fix apply user ownership to all files in the home directory
|
| 209 |
+
RUN chown -R user:user /home/user
|
| 210 |
+
|
| 211 |
+
# Set permissions for Python executable
|
| 212 |
+
RUN chmod 755 /usr/local/bin/python
|
| 213 |
+
|
| 214 |
+
# Declare volumes (NOTE: runtime mounts will override permissions β handle with care)
|
| 215 |
+
VOLUME ["/tmp/matplotlib_cache"]
|
| 216 |
+
VOLUME ["/tmp/gradio_tmp"]
|
| 217 |
+
VOLUME ["/tmp/tld"]
|
| 218 |
+
VOLUME ["/home/user/app/output"]
|
| 219 |
+
VOLUME ["/home/user/app/input"]
|
| 220 |
+
VOLUME ["/home/user/app/logs"]
|
| 221 |
+
VOLUME ["/home/user/app/usage"]
|
| 222 |
+
VOLUME ["/home/user/app/feedback"]
|
| 223 |
+
VOLUME ["/home/user/app/config"]
|
| 224 |
+
VOLUME ["/home/user/.paddlex"]
|
| 225 |
+
VOLUME ["/home/user/.local/share/spacy/data"]
|
| 226 |
+
VOLUME ["/usr/share/tessdata"]
|
| 227 |
+
VOLUME ["/tmp"]
|
| 228 |
+
VOLUME ["/var/tmp"]
|
| 229 |
+
|
| 230 |
+
USER user
|
| 231 |
+
|
| 232 |
+
EXPOSE $GRADIO_SERVER_PORT
|
| 233 |
+
|
| 234 |
+
ENTRYPOINT ["/home/user/app/entrypoint.sh"]
|
| 235 |
+
CMD ["python", "app.py"]
|
LICENSE
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
| 2 |
+
Version 3, 19 November 2007
|
| 3 |
+
|
| 4 |
+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
| 5 |
+
Everyone is permitted to copy and distribute verbatim copies
|
| 6 |
+
of this license document, but changing it is not allowed.
|
| 7 |
+
|
| 8 |
+
Preamble
|
| 9 |
+
|
| 10 |
+
The GNU Affero General Public License is a free, copyleft license for
|
| 11 |
+
software and other kinds of works, specifically designed to ensure
|
| 12 |
+
cooperation with the community in the case of network server software.
|
| 13 |
+
|
| 14 |
+
The licenses for most software and other practical works are designed
|
| 15 |
+
to take away your freedom to share and change the works. By contrast,
|
| 16 |
+
our General Public Licenses are intended to guarantee your freedom to
|
| 17 |
+
share and change all versions of a program--to make sure it remains free
|
| 18 |
+
software for all its users.
|
| 19 |
+
|
| 20 |
+
When we speak of free software, we are referring to freedom, not
|
| 21 |
+
price. Our General Public Licenses are designed to make sure that you
|
| 22 |
+
have the freedom to distribute copies of free software (and charge for
|
| 23 |
+
them if you wish), that you receive source code or can get it if you
|
| 24 |
+
want it, that you can change the software or use pieces of it in new
|
| 25 |
+
free programs, and that you know you can do these things.
|
| 26 |
+
|
| 27 |
+
Developers that use our General Public Licenses protect your rights
|
| 28 |
+
with two steps: (1) assert copyright on the software, and (2) offer
|
| 29 |
+
you this License which gives you legal permission to copy, distribute
|
| 30 |
+
and/or modify the software.
|
| 31 |
+
|
| 32 |
+
A secondary benefit of defending all users' freedom is that
|
| 33 |
+
improvements made in alternate versions of the program, if they
|
| 34 |
+
receive widespread use, become available for other developers to
|
| 35 |
+
incorporate. Many developers of free software are heartened and
|
| 36 |
+
encouraged by the resulting cooperation. However, in the case of
|
| 37 |
+
software used on network servers, this result may fail to come about.
|
| 38 |
+
The GNU General Public License permits making a modified version and
|
| 39 |
+
letting the public access it on a server without ever releasing its
|
| 40 |
+
source code to the public.
|
| 41 |
+
|
| 42 |
+
The GNU Affero General Public License is designed specifically to
|
| 43 |
+
ensure that, in such cases, the modified source code becomes available
|
| 44 |
+
to the community. It requires the operator of a network server to
|
| 45 |
+
provide the source code of the modified version running there to the
|
| 46 |
+
users of that server. Therefore, public use of a modified version, on
|
| 47 |
+
a publicly accessible server, gives the public access to the source
|
| 48 |
+
code of the modified version.
|
| 49 |
+
|
| 50 |
+
An older license, called the Affero General Public License and
|
| 51 |
+
published by Affero, was designed to accomplish similar goals. This is
|
| 52 |
+
a different license, not a version of the Affero GPL, but Affero has
|
| 53 |
+
released a new version of the Affero GPL which permits relicensing under
|
| 54 |
+
this license.
|
| 55 |
+
|
| 56 |
+
The precise terms and conditions for copying, distribution and
|
| 57 |
+
modification follow.
|
| 58 |
+
|
| 59 |
+
TERMS AND CONDITIONS
|
| 60 |
+
|
| 61 |
+
0. Definitions.
|
| 62 |
+
|
| 63 |
+
"This License" refers to version 3 of the GNU Affero General Public License.
|
| 64 |
+
|
| 65 |
+
"Copyright" also means copyright-like laws that apply to other kinds of
|
| 66 |
+
works, such as semiconductor masks.
|
| 67 |
+
|
| 68 |
+
"The Program" refers to any copyrightable work licensed under this
|
| 69 |
+
License. Each licensee is addressed as "you". "Licensees" and
|
| 70 |
+
"recipients" may be individuals or organizations.
|
| 71 |
+
|
| 72 |
+
To "modify" a work means to copy from or adapt all or part of the work
|
| 73 |
+
in a fashion requiring copyright permission, other than the making of an
|
| 74 |
+
exact copy. The resulting work is called a "modified version" of the
|
| 75 |
+
earlier work or a work "based on" the earlier work.
|
| 76 |
+
|
| 77 |
+
A "covered work" means either the unmodified Program or a work based
|
| 78 |
+
on the Program.
|
| 79 |
+
|
| 80 |
+
To "propagate" a work means to do anything with it that, without
|
| 81 |
+
permission, would make you directly or secondarily liable for
|
| 82 |
+
infringement under applicable copyright law, except executing it on a
|
| 83 |
+
computer or modifying a private copy. Propagation includes copying,
|
| 84 |
+
distribution (with or without modification), making available to the
|
| 85 |
+
public, and in some countries other activities as well.
|
| 86 |
+
|
| 87 |
+
To "convey" a work means any kind of propagation that enables other
|
| 88 |
+
parties to make or receive copies. Mere interaction with a user through
|
| 89 |
+
a computer network, with no transfer of a copy, is not conveying.
|
| 90 |
+
|
| 91 |
+
An interactive user interface displays "Appropriate Legal Notices"
|
| 92 |
+
to the extent that it includes a convenient and prominently visible
|
| 93 |
+
feature that (1) displays an appropriate copyright notice, and (2)
|
| 94 |
+
tells the user that there is no warranty for the work (except to the
|
| 95 |
+
extent that warranties are provided), that licensees may convey the
|
| 96 |
+
work under this License, and how to view a copy of this License. If
|
| 97 |
+
the interface presents a list of user commands or options, such as a
|
| 98 |
+
menu, a prominent item in the list meets this criterion.
|
| 99 |
+
|
| 100 |
+
1. Source Code.
|
| 101 |
+
|
| 102 |
+
The "source code" for a work means the preferred form of the work
|
| 103 |
+
for making modifications to it. "Object code" means any non-source
|
| 104 |
+
form of a work.
|
| 105 |
+
|
| 106 |
+
A "Standard Interface" means an interface that either is an official
|
| 107 |
+
standard defined by a recognized standards body, or, in the case of
|
| 108 |
+
interfaces specified for a particular programming language, one that
|
| 109 |
+
is widely used among developers working in that language.
|
| 110 |
+
|
| 111 |
+
The "System Libraries" of an executable work include anything, other
|
| 112 |
+
than the work as a whole, that (a) is included in the normal form of
|
| 113 |
+
packaging a Major Component, but which is not part of that Major
|
| 114 |
+
Component, and (b) serves only to enable use of the work with that
|
| 115 |
+
Major Component, or to implement a Standard Interface for which an
|
| 116 |
+
implementation is available to the public in source code form. A
|
| 117 |
+
"Major Component", in this context, means a major essential component
|
| 118 |
+
(kernel, window system, and so on) of the specific operating system
|
| 119 |
+
(if any) on which the executable work runs, or a compiler used to
|
| 120 |
+
produce the work, or an object code interpreter used to run it.
|
| 121 |
+
|
| 122 |
+
The "Corresponding Source" for a work in object code form means all
|
| 123 |
+
the source code needed to generate, install, and (for an executable
|
| 124 |
+
work) run the object code and to modify the work, including scripts to
|
| 125 |
+
control those activities. However, it does not include the work's
|
| 126 |
+
System Libraries, or general-purpose tools or generally available free
|
| 127 |
+
programs which are used unmodified in performing those activities but
|
| 128 |
+
which are not part of the work. For example, Corresponding Source
|
| 129 |
+
includes interface definition files associated with source files for
|
| 130 |
+
the work, and the source code for shared libraries and dynamically
|
| 131 |
+
linked subprograms that the work is specifically designed to require,
|
| 132 |
+
such as by intimate data communication or control flow between those
|
| 133 |
+
subprograms and other parts of the work.
|
| 134 |
+
|
| 135 |
+
The Corresponding Source need not include anything that users
|
| 136 |
+
can regenerate automatically from other parts of the Corresponding
|
| 137 |
+
Source.
|
| 138 |
+
|
| 139 |
+
The Corresponding Source for a work in source code form is that
|
| 140 |
+
same work.
|
| 141 |
+
|
| 142 |
+
2. Basic Permissions.
|
| 143 |
+
|
| 144 |
+
All rights granted under this License are granted for the term of
|
| 145 |
+
copyright on the Program, and are irrevocable provided the stated
|
| 146 |
+
conditions are met. This License explicitly affirms your unlimited
|
| 147 |
+
permission to run the unmodified Program. The output from running a
|
| 148 |
+
covered work is covered by this License only if the output, given its
|
| 149 |
+
content, constitutes a covered work. This License acknowledges your
|
| 150 |
+
rights of fair use or other equivalent, as provided by copyright law.
|
| 151 |
+
|
| 152 |
+
You may make, run and propagate covered works that you do not
|
| 153 |
+
convey, without conditions so long as your license otherwise remains
|
| 154 |
+
in force. You may convey covered works to others for the sole purpose
|
| 155 |
+
of having them make modifications exclusively for you, or provide you
|
| 156 |
+
with facilities for running those works, provided that you comply with
|
| 157 |
+
the terms of this License in conveying all material for which you do
|
| 158 |
+
not control copyright. Those thus making or running the covered works
|
| 159 |
+
for you must do so exclusively on your behalf, under your direction
|
| 160 |
+
and control, on terms that prohibit them from making any copies of
|
| 161 |
+
your copyrighted material outside their relationship with you.
|
| 162 |
+
|
| 163 |
+
Conveying under any other circumstances is permitted solely under
|
| 164 |
+
the conditions stated below. Sublicensing is not allowed; section 10
|
| 165 |
+
makes it unnecessary.
|
| 166 |
+
|
| 167 |
+
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
| 168 |
+
|
| 169 |
+
No covered work shall be deemed part of an effective technological
|
| 170 |
+
measure under any applicable law fulfilling obligations under article
|
| 171 |
+
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
| 172 |
+
similar laws prohibiting or restricting circumvention of such
|
| 173 |
+
measures.
|
| 174 |
+
|
| 175 |
+
When you convey a covered work, you waive any legal power to forbid
|
| 176 |
+
circumvention of technological measures to the extent such circumvention
|
| 177 |
+
is effected by exercising rights under this License with respect to
|
| 178 |
+
the covered work, and you disclaim any intention to limit operation or
|
| 179 |
+
modification of the work as a means of enforcing, against the work's
|
| 180 |
+
users, your or third parties' legal rights to forbid circumvention of
|
| 181 |
+
technological measures.
|
| 182 |
+
|
| 183 |
+
4. Conveying Verbatim Copies.
|
| 184 |
+
|
| 185 |
+
You may convey verbatim copies of the Program's source code as you
|
| 186 |
+
receive it, in any medium, provided that you conspicuously and
|
| 187 |
+
appropriately publish on each copy an appropriate copyright notice;
|
| 188 |
+
keep intact all notices stating that this License and any
|
| 189 |
+
non-permissive terms added in accord with section 7 apply to the code;
|
| 190 |
+
keep intact all notices of the absence of any warranty; and give all
|
| 191 |
+
recipients a copy of this License along with the Program.
|
| 192 |
+
|
| 193 |
+
You may charge any price or no price for each copy that you convey,
|
| 194 |
+
and you may offer support or warranty protection for a fee.
|
| 195 |
+
|
| 196 |
+
5. Conveying Modified Source Versions.
|
| 197 |
+
|
| 198 |
+
You may convey a work based on the Program, or the modifications to
|
| 199 |
+
produce it from the Program, in the form of source code under the
|
| 200 |
+
terms of section 4, provided that you also meet all of these conditions:
|
| 201 |
+
|
| 202 |
+
a) The work must carry prominent notices stating that you modified
|
| 203 |
+
it, and giving a relevant date.
|
| 204 |
+
|
| 205 |
+
b) The work must carry prominent notices stating that it is
|
| 206 |
+
released under this License and any conditions added under section
|
| 207 |
+
7. This requirement modifies the requirement in section 4 to
|
| 208 |
+
"keep intact all notices".
|
| 209 |
+
|
| 210 |
+
c) You must license the entire work, as a whole, under this
|
| 211 |
+
License to anyone who comes into possession of a copy. This
|
| 212 |
+
License will therefore apply, along with any applicable section 7
|
| 213 |
+
additional terms, to the whole of the work, and all its parts,
|
| 214 |
+
regardless of how they are packaged. This License gives no
|
| 215 |
+
permission to license the work in any other way, but it does not
|
| 216 |
+
invalidate such permission if you have separately received it.
|
| 217 |
+
|
| 218 |
+
d) If the work has interactive user interfaces, each must display
|
| 219 |
+
Appropriate Legal Notices; however, if the Program has interactive
|
| 220 |
+
interfaces that do not display Appropriate Legal Notices, your
|
| 221 |
+
work need not make them do so.
|
| 222 |
+
|
| 223 |
+
A compilation of a covered work with other separate and independent
|
| 224 |
+
works, which are not by their nature extensions of the covered work,
|
| 225 |
+
and which are not combined with it such as to form a larger program,
|
| 226 |
+
in or on a volume of a storage or distribution medium, is called an
|
| 227 |
+
"aggregate" if the compilation and its resulting copyright are not
|
| 228 |
+
used to limit the access or legal rights of the compilation's users
|
| 229 |
+
beyond what the individual works permit. Inclusion of a covered work
|
| 230 |
+
in an aggregate does not cause this License to apply to the other
|
| 231 |
+
parts of the aggregate.
|
| 232 |
+
|
| 233 |
+
6. Conveying Non-Source Forms.
|
| 234 |
+
|
| 235 |
+
You may convey a covered work in object code form under the terms
|
| 236 |
+
of sections 4 and 5, provided that you also convey the
|
| 237 |
+
machine-readable Corresponding Source under the terms of this License,
|
| 238 |
+
in one of these ways:
|
| 239 |
+
|
| 240 |
+
a) Convey the object code in, or embodied in, a physical product
|
| 241 |
+
(including a physical distribution medium), accompanied by the
|
| 242 |
+
Corresponding Source fixed on a durable physical medium
|
| 243 |
+
customarily used for software interchange.
|
| 244 |
+
|
| 245 |
+
b) Convey the object code in, or embodied in, a physical product
|
| 246 |
+
(including a physical distribution medium), accompanied by a
|
| 247 |
+
written offer, valid for at least three years and valid for as
|
| 248 |
+
long as you offer spare parts or customer support for that product
|
| 249 |
+
model, to give anyone who possesses the object code either (1) a
|
| 250 |
+
copy of the Corresponding Source for all the software in the
|
| 251 |
+
product that is covered by this License, on a durable physical
|
| 252 |
+
medium customarily used for software interchange, for a price no
|
| 253 |
+
more than your reasonable cost of physically performing this
|
| 254 |
+
conveying of source, or (2) access to copy the
|
| 255 |
+
Corresponding Source from a network server at no charge.
|
| 256 |
+
|
| 257 |
+
c) Convey individual copies of the object code with a copy of the
|
| 258 |
+
written offer to provide the Corresponding Source. This
|
| 259 |
+
alternative is allowed only occasionally and noncommercially, and
|
| 260 |
+
only if you received the object code with such an offer, in accord
|
| 261 |
+
with subsection 6b.
|
| 262 |
+
|
| 263 |
+
d) Convey the object code by offering access from a designated
|
| 264 |
+
place (gratis or for a charge), and offer equivalent access to the
|
| 265 |
+
Corresponding Source in the same way through the same place at no
|
| 266 |
+
further charge. You need not require recipients to copy the
|
| 267 |
+
Corresponding Source along with the object code. If the place to
|
| 268 |
+
copy the object code is a network server, the Corresponding Source
|
| 269 |
+
may be on a different server (operated by you or a third party)
|
| 270 |
+
that supports equivalent copying facilities, provided you maintain
|
| 271 |
+
clear directions next to the object code saying where to find the
|
| 272 |
+
Corresponding Source. Regardless of what server hosts the
|
| 273 |
+
Corresponding Source, you remain obligated to ensure that it is
|
| 274 |
+
available for as long as needed to satisfy these requirements.
|
| 275 |
+
|
| 276 |
+
e) Convey the object code using peer-to-peer transmission, provided
|
| 277 |
+
you inform other peers where the object code and Corresponding
|
| 278 |
+
Source of the work are being offered to the general public at no
|
| 279 |
+
charge under subsection 6d.
|
| 280 |
+
|
| 281 |
+
A separable portion of the object code, whose source code is excluded
|
| 282 |
+
from the Corresponding Source as a System Library, need not be
|
| 283 |
+
included in conveying the object code work.
|
| 284 |
+
|
| 285 |
+
A "User Product" is either (1) a "consumer product", which means any
|
| 286 |
+
tangible personal property which is normally used for personal, family,
|
| 287 |
+
or household purposes, or (2) anything designed or sold for incorporation
|
| 288 |
+
into a dwelling. In determining whether a product is a consumer product,
|
| 289 |
+
doubtful cases shall be resolved in favor of coverage. For a particular
|
| 290 |
+
product received by a particular user, "normally used" refers to a
|
| 291 |
+
typical or common use of that class of product, regardless of the status
|
| 292 |
+
of the particular user or of the way in which the particular user
|
| 293 |
+
actually uses, or expects or is expected to use, the product. A product
|
| 294 |
+
is a consumer product regardless of whether the product has substantial
|
| 295 |
+
commercial, industrial or non-consumer uses, unless such uses represent
|
| 296 |
+
the only significant mode of use of the product.
|
| 297 |
+
|
| 298 |
+
"Installation Information" for a User Product means any methods,
|
| 299 |
+
procedures, authorization keys, or other information required to install
|
| 300 |
+
and execute modified versions of a covered work in that User Product from
|
| 301 |
+
a modified version of its Corresponding Source. The information must
|
| 302 |
+
suffice to ensure that the continued functioning of the modified object
|
| 303 |
+
code is in no case prevented or interfered with solely because
|
| 304 |
+
modification has been made.
|
| 305 |
+
|
| 306 |
+
If you convey an object code work under this section in, or with, or
|
| 307 |
+
specifically for use in, a User Product, and the conveying occurs as
|
| 308 |
+
part of a transaction in which the right of possession and use of the
|
| 309 |
+
User Product is transferred to the recipient in perpetuity or for a
|
| 310 |
+
fixed term (regardless of how the transaction is characterized), the
|
| 311 |
+
Corresponding Source conveyed under this section must be accompanied
|
| 312 |
+
by the Installation Information. But this requirement does not apply
|
| 313 |
+
if neither you nor any third party retains the ability to install
|
| 314 |
+
modified object code on the User Product (for example, the work has
|
| 315 |
+
been installed in ROM).
|
| 316 |
+
|
| 317 |
+
The requirement to provide Installation Information does not include a
|
| 318 |
+
requirement to continue to provide support service, warranty, or updates
|
| 319 |
+
for a work that has been modified or installed by the recipient, or for
|
| 320 |
+
the User Product in which it has been modified or installed. Access to a
|
| 321 |
+
network may be denied when the modification itself materially and
|
| 322 |
+
adversely affects the operation of the network or violates the rules and
|
| 323 |
+
protocols for communication across the network.
|
| 324 |
+
|
| 325 |
+
Corresponding Source conveyed, and Installation Information provided,
|
| 326 |
+
in accord with this section must be in a format that is publicly
|
| 327 |
+
documented (and with an implementation available to the public in
|
| 328 |
+
source code form), and must require no special password or key for
|
| 329 |
+
unpacking, reading or copying.
|
| 330 |
+
|
| 331 |
+
7. Additional Terms.
|
| 332 |
+
|
| 333 |
+
"Additional permissions" are terms that supplement the terms of this
|
| 334 |
+
License by making exceptions from one or more of its conditions.
|
| 335 |
+
Additional permissions that are applicable to the entire Program shall
|
| 336 |
+
be treated as though they were included in this License, to the extent
|
| 337 |
+
that they are valid under applicable law. If additional permissions
|
| 338 |
+
apply only to part of the Program, that part may be used separately
|
| 339 |
+
under those permissions, but the entire Program remains governed by
|
| 340 |
+
this License without regard to the additional permissions.
|
| 341 |
+
|
| 342 |
+
When you convey a copy of a covered work, you may at your option
|
| 343 |
+
remove any additional permissions from that copy, or from any part of
|
| 344 |
+
it. (Additional permissions may be written to require their own
|
| 345 |
+
removal in certain cases when you modify the work.) You may place
|
| 346 |
+
additional permissions on material, added by you to a covered work,
|
| 347 |
+
for which you have or can give appropriate copyright permission.
|
| 348 |
+
|
| 349 |
+
Notwithstanding any other provision of this License, for material you
|
| 350 |
+
add to a covered work, you may (if authorized by the copyright holders of
|
| 351 |
+
that material) supplement the terms of this License with terms:
|
| 352 |
+
|
| 353 |
+
a) Disclaiming warranty or limiting liability differently from the
|
| 354 |
+
terms of sections 15 and 16 of this License; or
|
| 355 |
+
|
| 356 |
+
b) Requiring preservation of specified reasonable legal notices or
|
| 357 |
+
author attributions in that material or in the Appropriate Legal
|
| 358 |
+
Notices displayed by works containing it; or
|
| 359 |
+
|
| 360 |
+
c) Prohibiting misrepresentation of the origin of that material, or
|
| 361 |
+
requiring that modified versions of such material be marked in
|
| 362 |
+
reasonable ways as different from the original version; or
|
| 363 |
+
|
| 364 |
+
d) Limiting the use for publicity purposes of names of licensors or
|
| 365 |
+
authors of the material; or
|
| 366 |
+
|
| 367 |
+
e) Declining to grant rights under trademark law for use of some
|
| 368 |
+
trade names, trademarks, or service marks; or
|
| 369 |
+
|
| 370 |
+
f) Requiring indemnification of licensors and authors of that
|
| 371 |
+
material by anyone who conveys the material (or modified versions of
|
| 372 |
+
it) with contractual assumptions of liability to the recipient, for
|
| 373 |
+
any liability that these contractual assumptions directly impose on
|
| 374 |
+
those licensors and authors.
|
| 375 |
+
|
| 376 |
+
All other non-permissive additional terms are considered "further
|
| 377 |
+
restrictions" within the meaning of section 10. If the Program as you
|
| 378 |
+
received it, or any part of it, contains a notice stating that it is
|
| 379 |
+
governed by this License along with a term that is a further
|
| 380 |
+
restriction, you may remove that term. If a license document contains
|
| 381 |
+
a further restriction but permits relicensing or conveying under this
|
| 382 |
+
License, you may add to a covered work material governed by the terms
|
| 383 |
+
of that license document, provided that the further restriction does
|
| 384 |
+
not survive such relicensing or conveying.
|
| 385 |
+
|
| 386 |
+
If you add terms to a covered work in accord with this section, you
|
| 387 |
+
must place, in the relevant source files, a statement of the
|
| 388 |
+
additional terms that apply to those files, or a notice indicating
|
| 389 |
+
where to find the applicable terms.
|
| 390 |
+
|
| 391 |
+
Additional terms, permissive or non-permissive, may be stated in the
|
| 392 |
+
form of a separately written license, or stated as exceptions;
|
| 393 |
+
the above requirements apply either way.
|
| 394 |
+
|
| 395 |
+
8. Termination.
|
| 396 |
+
|
| 397 |
+
You may not propagate or modify a covered work except as expressly
|
| 398 |
+
provided under this License. Any attempt otherwise to propagate or
|
| 399 |
+
modify it is void, and will automatically terminate your rights under
|
| 400 |
+
this License (including any patent licenses granted under the third
|
| 401 |
+
paragraph of section 11).
|
| 402 |
+
|
| 403 |
+
However, if you cease all violation of this License, then your
|
| 404 |
+
license from a particular copyright holder is reinstated (a)
|
| 405 |
+
provisionally, unless and until the copyright holder explicitly and
|
| 406 |
+
finally terminates your license, and (b) permanently, if the copyright
|
| 407 |
+
holder fails to notify you of the violation by some reasonable means
|
| 408 |
+
prior to 60 days after the cessation.
|
| 409 |
+
|
| 410 |
+
Moreover, your license from a particular copyright holder is
|
| 411 |
+
reinstated permanently if the copyright holder notifies you of the
|
| 412 |
+
violation by some reasonable means, this is the first time you have
|
| 413 |
+
received notice of violation of this License (for any work) from that
|
| 414 |
+
copyright holder, and you cure the violation prior to 30 days after
|
| 415 |
+
your receipt of the notice.
|
| 416 |
+
|
| 417 |
+
Termination of your rights under this section does not terminate the
|
| 418 |
+
licenses of parties who have received copies or rights from you under
|
| 419 |
+
this License. If your rights have been terminated and not permanently
|
| 420 |
+
reinstated, you do not qualify to receive new licenses for the same
|
| 421 |
+
material under section 10.
|
| 422 |
+
|
| 423 |
+
9. Acceptance Not Required for Having Copies.
|
| 424 |
+
|
| 425 |
+
You are not required to accept this License in order to receive or
|
| 426 |
+
run a copy of the Program. Ancillary propagation of a covered work
|
| 427 |
+
occurring solely as a consequence of using peer-to-peer transmission
|
| 428 |
+
to receive a copy likewise does not require acceptance. However,
|
| 429 |
+
nothing other than this License grants you permission to propagate or
|
| 430 |
+
modify any covered work. These actions infringe copyright if you do
|
| 431 |
+
not accept this License. Therefore, by modifying or propagating a
|
| 432 |
+
covered work, you indicate your acceptance of this License to do so.
|
| 433 |
+
|
| 434 |
+
10. Automatic Licensing of Downstream Recipients.
|
| 435 |
+
|
| 436 |
+
Each time you convey a covered work, the recipient automatically
|
| 437 |
+
receives a license from the original licensors, to run, modify and
|
| 438 |
+
propagate that work, subject to this License. You are not responsible
|
| 439 |
+
for enforcing compliance by third parties with this License.
|
| 440 |
+
|
| 441 |
+
An "entity transaction" is a transaction transferring control of an
|
| 442 |
+
organization, or substantially all assets of one, or subdividing an
|
| 443 |
+
organization, or merging organizations. If propagation of a covered
|
| 444 |
+
work results from an entity transaction, each party to that
|
| 445 |
+
transaction who receives a copy of the work also receives whatever
|
| 446 |
+
licenses to the work the party's predecessor in interest had or could
|
| 447 |
+
give under the previous paragraph, plus a right to possession of the
|
| 448 |
+
Corresponding Source of the work from the predecessor in interest, if
|
| 449 |
+
the predecessor has it or can get it with reasonable efforts.
|
| 450 |
+
|
| 451 |
+
You may not impose any further restrictions on the exercise of the
|
| 452 |
+
rights granted or affirmed under this License. For example, you may
|
| 453 |
+
not impose a license fee, royalty, or other charge for exercise of
|
| 454 |
+
rights granted under this License, and you may not initiate litigation
|
| 455 |
+
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
| 456 |
+
any patent claim is infringed by making, using, selling, offering for
|
| 457 |
+
sale, or importing the Program or any portion of it.
|
| 458 |
+
|
| 459 |
+
11. Patents.
|
| 460 |
+
|
| 461 |
+
A "contributor" is a copyright holder who authorizes use under this
|
| 462 |
+
License of the Program or a work on which the Program is based. The
|
| 463 |
+
work thus licensed is called the contributor's "contributor version".
|
| 464 |
+
|
| 465 |
+
A contributor's "essential patent claims" are all patent claims
|
| 466 |
+
owned or controlled by the contributor, whether already acquired or
|
| 467 |
+
hereafter acquired, that would be infringed by some manner, permitted
|
| 468 |
+
by this License, of making, using, or selling its contributor version,
|
| 469 |
+
but do not include claims that would be infringed only as a
|
| 470 |
+
consequence of further modification of the contributor version. For
|
| 471 |
+
purposes of this definition, "control" includes the right to grant
|
| 472 |
+
patent sublicenses in a manner consistent with the requirements of
|
| 473 |
+
this License.
|
| 474 |
+
|
| 475 |
+
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
| 476 |
+
patent license under the contributor's essential patent claims, to
|
| 477 |
+
make, use, sell, offer for sale, import and otherwise run, modify and
|
| 478 |
+
propagate the contents of its contributor version.
|
| 479 |
+
|
| 480 |
+
In the following three paragraphs, a "patent license" is any express
|
| 481 |
+
agreement or commitment, however denominated, not to enforce a patent
|
| 482 |
+
(such as an express permission to practice a patent or covenant not to
|
| 483 |
+
sue for patent infringement). To "grant" such a patent license to a
|
| 484 |
+
party means to make such an agreement or commitment not to enforce a
|
| 485 |
+
patent against the party.
|
| 486 |
+
|
| 487 |
+
If you convey a covered work, knowingly relying on a patent license,
|
| 488 |
+
and the Corresponding Source of the work is not available for anyone
|
| 489 |
+
to copy, free of charge and under the terms of this License, through a
|
| 490 |
+
publicly available network server or other readily accessible means,
|
| 491 |
+
then you must either (1) cause the Corresponding Source to be so
|
| 492 |
+
available, or (2) arrange to deprive yourself of the benefit of the
|
| 493 |
+
patent license for this particular work, or (3) arrange, in a manner
|
| 494 |
+
consistent with the requirements of this License, to extend the patent
|
| 495 |
+
license to downstream recipients. "Knowingly relying" means you have
|
| 496 |
+
actual knowledge that, but for the patent license, your conveying the
|
| 497 |
+
covered work in a country, or your recipient's use of the covered work
|
| 498 |
+
in a country, would infringe one or more identifiable patents in that
|
| 499 |
+
country that you have reason to believe are valid.
|
| 500 |
+
|
| 501 |
+
If, pursuant to or in connection with a single transaction or
|
| 502 |
+
arrangement, you convey, or propagate by procuring conveyance of, a
|
| 503 |
+
covered work, and grant a patent license to some of the parties
|
| 504 |
+
receiving the covered work authorizing them to use, propagate, modify
|
| 505 |
+
or convey a specific copy of the covered work, then the patent license
|
| 506 |
+
you grant is automatically extended to all recipients of the covered
|
| 507 |
+
work and works based on it.
|
| 508 |
+
|
| 509 |
+
A patent license is "discriminatory" if it does not include within
|
| 510 |
+
the scope of its coverage, prohibits the exercise of, or is
|
| 511 |
+
conditioned on the non-exercise of one or more of the rights that are
|
| 512 |
+
specifically granted under this License. You may not convey a covered
|
| 513 |
+
work if you are a party to an arrangement with a third party that is
|
| 514 |
+
in the business of distributing software, under which you make payment
|
| 515 |
+
to the third party based on the extent of your activity of conveying
|
| 516 |
+
the work, and under which the third party grants, to any of the
|
| 517 |
+
parties who would receive the covered work from you, a discriminatory
|
| 518 |
+
patent license (a) in connection with copies of the covered work
|
| 519 |
+
conveyed by you (or copies made from those copies), or (b) primarily
|
| 520 |
+
for and in connection with specific products or compilations that
|
| 521 |
+
contain the covered work, unless you entered into that arrangement,
|
| 522 |
+
or that patent license was granted, prior to 28 March 2007.
|
| 523 |
+
|
| 524 |
+
Nothing in this License shall be construed as excluding or limiting
|
| 525 |
+
any implied license or other defenses to infringement that may
|
| 526 |
+
otherwise be available to you under applicable patent law.
|
| 527 |
+
|
| 528 |
+
12. No Surrender of Others' Freedom.
|
| 529 |
+
|
| 530 |
+
If conditions are imposed on you (whether by court order, agreement or
|
| 531 |
+
otherwise) that contradict the conditions of this License, they do not
|
| 532 |
+
excuse you from the conditions of this License. If you cannot convey a
|
| 533 |
+
covered work so as to satisfy simultaneously your obligations under this
|
| 534 |
+
License and any other pertinent obligations, then as a consequence you may
|
| 535 |
+
not convey it at all. For example, if you agree to terms that obligate you
|
| 536 |
+
to collect a royalty for further conveying from those to whom you convey
|
| 537 |
+
the Program, the only way you could satisfy both those terms and this
|
| 538 |
+
License would be to refrain entirely from conveying the Program.
|
| 539 |
+
|
| 540 |
+
13. Remote Network Interaction; Use with the GNU General Public License.
|
| 541 |
+
|
| 542 |
+
Notwithstanding any other provision of this License, if you modify the
|
| 543 |
+
Program, your modified version must prominently offer all users
|
| 544 |
+
interacting with it remotely through a computer network (if your version
|
| 545 |
+
supports such interaction) an opportunity to receive the Corresponding
|
| 546 |
+
Source of your version by providing access to the Corresponding Source
|
| 547 |
+
from a network server at no charge, through some standard or customary
|
| 548 |
+
means of facilitating copying of software. This Corresponding Source
|
| 549 |
+
shall include the Corresponding Source for any work covered by version 3
|
| 550 |
+
of the GNU General Public License that is incorporated pursuant to the
|
| 551 |
+
following paragraph.
|
| 552 |
+
|
| 553 |
+
Notwithstanding any other provision of this License, you have
|
| 554 |
+
permission to link or combine any covered work with a work licensed
|
| 555 |
+
under version 3 of the GNU General Public License into a single
|
| 556 |
+
combined work, and to convey the resulting work. The terms of this
|
| 557 |
+
License will continue to apply to the part which is the covered work,
|
| 558 |
+
but the work with which it is combined will remain governed by version
|
| 559 |
+
3 of the GNU General Public License.
|
| 560 |
+
|
| 561 |
+
14. Revised Versions of this License.
|
| 562 |
+
|
| 563 |
+
The Free Software Foundation may publish revised and/or new versions of
|
| 564 |
+
the GNU Affero General Public License from time to time. Such new versions
|
| 565 |
+
will be similar in spirit to the present version, but may differ in detail to
|
| 566 |
+
address new problems or concerns.
|
| 567 |
+
|
| 568 |
+
Each version is given a distinguishing version number. If the
|
| 569 |
+
Program specifies that a certain numbered version of the GNU Affero General
|
| 570 |
+
Public License "or any later version" applies to it, you have the
|
| 571 |
+
option of following the terms and conditions either of that numbered
|
| 572 |
+
version or of any later version published by the Free Software
|
| 573 |
+
Foundation. If the Program does not specify a version number of the
|
| 574 |
+
GNU Affero General Public License, you may choose any version ever published
|
| 575 |
+
by the Free Software Foundation.
|
| 576 |
+
|
| 577 |
+
If the Program specifies that a proxy can decide which future
|
| 578 |
+
versions of the GNU Affero General Public License can be used, that proxy's
|
| 579 |
+
public statement of acceptance of a version permanently authorizes you
|
| 580 |
+
to choose that version for the Program.
|
| 581 |
+
|
| 582 |
+
Later license versions may give you additional or different
|
| 583 |
+
permissions. However, no additional obligations are imposed on any
|
| 584 |
+
author or copyright holder as a result of your choosing to follow a
|
| 585 |
+
later version.
|
| 586 |
+
|
| 587 |
+
15. Disclaimer of Warranty.
|
| 588 |
+
|
| 589 |
+
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
| 590 |
+
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
| 591 |
+
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
| 592 |
+
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
| 593 |
+
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
| 594 |
+
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
| 595 |
+
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
| 596 |
+
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
| 597 |
+
|
| 598 |
+
16. Limitation of Liability.
|
| 599 |
+
|
| 600 |
+
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
| 601 |
+
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
| 602 |
+
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
| 603 |
+
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
| 604 |
+
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
| 605 |
+
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
| 606 |
+
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
| 607 |
+
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
| 608 |
+
SUCH DAMAGES.
|
| 609 |
+
|
| 610 |
+
17. Interpretation of Sections 15 and 16.
|
| 611 |
+
|
| 612 |
+
If the disclaimer of warranty and limitation of liability provided
|
| 613 |
+
above cannot be given local legal effect according to their terms,
|
| 614 |
+
reviewing courts shall apply local law that most closely approximates
|
| 615 |
+
an absolute waiver of all civil liability in connection with the
|
| 616 |
+
Program, unless a warranty or assumption of liability accompanies a
|
| 617 |
+
copy of the Program in return for a fee.
|
| 618 |
+
|
| 619 |
+
END OF TERMS AND CONDITIONS
|
| 620 |
+
|
| 621 |
+
How to Apply These Terms to Your New Programs
|
| 622 |
+
|
| 623 |
+
If you develop a new program, and you want it to be of the greatest
|
| 624 |
+
possible use to the public, the best way to achieve this is to make it
|
| 625 |
+
free software which everyone can redistribute and change under these terms.
|
| 626 |
+
|
| 627 |
+
To do so, attach the following notices to the program. It is safest
|
| 628 |
+
to attach them to the start of each source file to most effectively
|
| 629 |
+
state the exclusion of warranty; and each file should have at least
|
| 630 |
+
the "copyright" line and a pointer to where the full notice is found.
|
| 631 |
+
|
| 632 |
+
<one line to give the program's name and a brief idea of what it does.>
|
| 633 |
+
Copyright (C) <year> <name of author>
|
| 634 |
+
|
| 635 |
+
This program is free software: you can redistribute it and/or modify
|
| 636 |
+
it under the terms of the GNU Affero General Public License as published by
|
| 637 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 638 |
+
(at your option) any later version.
|
| 639 |
+
|
| 640 |
+
This program is distributed in the hope that it will be useful,
|
| 641 |
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 642 |
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 643 |
+
GNU Affero General Public License for more details.
|
| 644 |
+
|
| 645 |
+
You should have received a copy of the GNU Affero General Public License
|
| 646 |
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
| 647 |
+
|
| 648 |
+
Also add information on how to contact you by electronic and paper mail.
|
| 649 |
+
|
| 650 |
+
If your software can interact with users remotely through a computer
|
| 651 |
+
network, you should also make sure that it provides a way for users to
|
| 652 |
+
get its source. For example, if your program is a web application, its
|
| 653 |
+
interface could display a "Source" link that leads users to an archive
|
| 654 |
+
of the code. There are many ways you could offer source, and different
|
| 655 |
+
solutions will be better for different programs; see section 13 for the
|
| 656 |
+
specific requirements.
|
| 657 |
+
|
| 658 |
+
You should also get your employer (if you work as a programmer) or school,
|
| 659 |
+
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
| 660 |
+
For more information on this, and how to apply and follow the GNU AGPL, see
|
| 661 |
+
<https://www.gnu.org/licenses/>.
|
MANIFEST.in
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
recursive-include doc_redaction/assets *.png
|
| 2 |
+
recursive-include doc_redaction/example_data *
|
| 3 |
+
recursive-include intros *.txt
|
| 4 |
+
|
README.md
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Document redaction
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: true
|
| 9 |
+
license: agpl-3.0
|
| 10 |
+
short_description: OCR / redact PDF documents and tabular data
|
| 11 |
+
---
|
| 12 |
+
# Document redaction (doc_redaction)
|
| 13 |
+
|
| 14 |
+
<a href="https://pypi.org/project/doc-redaction/" target="_blank"><img alt="PyPI - Version" src="https://img.shields.io/pypi/v/doc-redaction"></a>
|
| 15 |
+
|
| 16 |
+
Redact personally identifiable information (PII) from documents (PDF, PNG, JPG), Word files (DOCX), or tabular data (XLSX/CSV/Parquet). Please see the [User Guide](https://seanpedrick-case.github.io/doc_redaction/src/user_guide.html) for a full walkthrough of all the features in the app.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## π Quick Start - Installation and first run
|
| 21 |
+
|
| 22 |
+
Follow these instructions to get the document redaction application running on your local machine.
|
| 23 |
+
|
| 24 |
+
### 1. Installation
|
| 25 |
+
|
| 26 |
+
#### Option 1 - Recommended: Install from source repo
|
| 27 |
+
|
| 28 |
+
Clone the repository and install in editable mode:
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
git clone https://github.com/seanpedrick-case/doc_redaction.git
|
| 32 |
+
cd doc_redaction
|
| 33 |
+
pip install -e .
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
##### Install extras (Paddle or Transformers/Torch VLM)
|
| 37 |
+
|
| 38 |
+
To install with PaddleOCR (with a transformers backend as of v2.4.0):
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
pip install -e ".[paddle]"
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
If you want to run VLMs / LLMs with the transformers package:
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
pip install -e ".[vlm]"
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
Note that the versions of both PaddleOCR and Torch installed by default are the CPU-only versions. If you want to install the GPU-enabled version of torch, it is advised to install the following version:
|
| 53 |
+
```bash
|
| 54 |
+
pip install torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu129
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
#### Option 2 - Install from PyPI
|
| 58 |
+
|
| 59 |
+
Create a virtual environment (recommended) and install **doc_redaction**.
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
python -m venv venv
|
| 63 |
+
# Windows:
|
| 64 |
+
.\venv\Scripts\activate
|
| 65 |
+
# macOS/Linux:
|
| 66 |
+
source venv/bin/activate
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
The package is published on PyPI as **`doc-redaction`** (import name **`doc_redaction`**):
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
pip install doc_redaction
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
Optional extras (same as in `pyproject.toml`). For installing paddleOCR:
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
pip install "doc_redaction[paddle]"
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
For running VLMs / LLMs with the transformers package:
|
| 82 |
+
|
| 83 |
+
```bash
|
| 84 |
+
pip install "doc_redaction[vlm]"
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
For programmatic use (CLI-first API matching Gradio `api_name` routes), see **[Python Package usage (Python)](https://seanpedrick-case.github.io/doc_redaction/src/python_package_usage.html)**. The console script **`cli_redact`** is available after install.
|
| 88 |
+
|
| 89 |
+
**Web UI from a PyPI install:** You *can* start the Gradio UI after `pip install doc_redaction` by running (note that the prerequisites tesseract and poppler will need to be correctly installed following step 2 below):
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
python -m app
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
**Important: your working directory matters.** When you run `python -m app`, the app treats your *current folder* as the βapp folderβ:
|
| 96 |
+
|
| 97 |
+
- It will look for configuration at `config/app_config.env` *relative to the folder you run it from* (and `python -m doc_redaction.install_deps` will also write `config/app_config.env` there).
|
| 98 |
+
- It may create new folders in that location (for example `config/`, `output/`, `input/`, `logs/`, `usage/`, `feedback/`, and temporary/cache folders depending on your settings).
|
| 99 |
+
- The UI example files and bundled assets are packaged with the PyPI install (they live inside the installed `doc_redaction` package). If you run from a βrandomβ directory after a PyPI install, the app can still locate its packaged examples; your working directory mainly affects where `config/`, `input/`, `output/`, logs, and temp folders are created.
|
| 100 |
+
|
| 101 |
+
In practice, the **smoothest UI experience** (examples, bundled assets, docs links, predictable relative paths) is still usually via a **repository checkout** or **Docker**, but PyPI install is sufficient to launch the UI as long as you run it from a suitable working folder and have the system dependencies available (or run `python -m doc_redaction.install_deps` first).
|
| 102 |
+
|
| 103 |
+
#### Option 3 - Docker installation
|
| 104 |
+
|
| 105 |
+
The doc_redaction Redaction app can be installed by using the [Dockerfile](https://github.com/seanpedrick-case/doc_redaction/blob/main/Dockerfile) or Docker compose files ([llama.cpp](https://github.com/ggml-org/llama.cpp), [vLLM](https://docs.vllm.ai/en/stable/)) provided in the repo.
|
| 106 |
+
|
| 107 |
+
##### With Llama.cpp / vLLM inference server
|
| 108 |
+
|
| 109 |
+
The project now has Docker and Docker compose files available to pair running the Redaction app with local inference servers powered by [llama.cpp](https://github.com/ggml-org/llama.cpp), or [vLLM](https://docs.vllm.ai/en/stable/). Llama.cpp is more flexible than vLLM for low VRAM systems, as Llama.cpp will offload to cpu/system RAM automatically rather than failing as vLLM tends to do.
|
| 110 |
+
|
| 111 |
+
For Llama.cpp, you can use the [docker-compose_llama.yml](https://github.com/seanpedrick-case/doc_redaction/blob/main/docker-compose_llama.yml) file, and for vLLM, you can use the [docker-compose_vllm.yml](https://github.com/seanpedrick-case/doc_redaction/blob/main/docker-compose_vllm.yml) file. To run, Docker / Docker Desktop should be installed, and then you can run the commands suggested in the top of the files to run the servers.
|
| 112 |
+
|
| 113 |
+
You will need ~40 GB of disk space to run everything depending on the model chosen from the compose file. For the vLLM server, you will need 24 GB VRAM. For the Llama.cpp server, 24 GB VRAM is needed to run at full speed, but the n-gpu-layers and n-cpu-moe parameters in the Docker compose file can be adjusted to fit into your system. I would suggest that 8 GB VRAM is needed as a bare minimum for decent inference speed. See the [Unsloth guide](https://unsloth.ai/docs/models/qwen3.5) for more details on working with GGUF files for Qwen 3.5.
|
| 114 |
+
|
| 115 |
+
##### Without Llama.cpp / vLLM inference server
|
| 116 |
+
|
| 117 |
+
If you want a working Docker installation without GPU support, you can install from the [Dockerfile](https://github.com/seanpedrick-case/doc_redaction/blob/main/Dockerfile) in the repo. A working example of this, with the CPU version of PaddleOCR, can be found on [Hugging Face](https://huggingface.co/spaces/seanpedrickcase/document_redaction). You can adjust the INSTALL_PADDLEOCR, PADDLE_GPU_ENABLED, INSTALL_VLM, and TORCH_GPU_ENABLED config variables to adjust for PaddleOCR and Transformers packages for local VLM support. Note that GPU-enabled PaddleOCR, and GPU-enabled Transformers/Torch often don't work well together, which is one reason why a Llama.cpp/vLLM inference server Docker installation option is provided below.
|
| 118 |
+
|
| 119 |
+
The main [Dockerfile](https://github.com/seanpedrick-case/doc_redaction/blob/main/Dockerfile) produces two final images via build targets: **`gradio`** (default web UI, non-root user, named volumes for writable paths) and **`lambda`** (AWS Lambda handler). Build examples:
|
| 120 |
+
|
| 121 |
+
```bash
|
| 122 |
+
docker build -f Dockerfile --target gradio -t doc-redaction-gradio .
|
| 123 |
+
docker build -f Dockerfile --target lambda -t doc-redaction-lambda .
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
##### Pi agent (agentic redaction)
|
| 127 |
+
|
| 128 |
+
The [Pi](https://github.com/earendil-works/pi) orchestration UI uses a separate multi-stage image at [agent-redact/pi-agent/Dockerfile](https://github.com/seanpedrick-case/doc_redaction/blob/main/agent-redact/pi-agent/Dockerfile). It shares the same Python 3.12 slim base as the main app; a small Node stage installs the `pi` CLI, which is copied into the runtime image.
|
| 129 |
+
|
| 130 |
+
| Build target | Typical use |
|
| 131 |
+
|--------------|-------------|
|
| 132 |
+
| **`dev`** | Local development with [docker-compose_llama_agentic.yml](https://github.com/seanpedrick-case/doc_redaction/blob/main/docker-compose_llama_agentic.yml) β the repo is bind-mounted; only Pi CLI + Python deps are in the image. |
|
| 133 |
+
| **`runtime`** | [Hugging Face Space](https://huggingface.co/spaces/seanpedrickcase/agentic_document_redaction) and AWS ECS β agent code is baked in; runs as non-root `user` with **named volumes** for workspace, uploads, and session dirs (read-only root filesystem friendly). |
|
| 134 |
+
|
| 135 |
+
Build from the repository root:
|
| 136 |
+
|
| 137 |
+
```bash
|
| 138 |
+
docker build -f agent-redact/pi-agent/Dockerfile --target dev -t pi-agent-dev .
|
| 139 |
+
docker build -f agent-redact/pi-agent/Dockerfile --target runtime -t pi-agent-runtime .
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
For llama.cpp + Pi together, see the compose examples at the top of [docker-compose_llama_agentic.yml](https://github.com/seanpedrick-case/doc_redaction/blob/main/docker-compose_llama_agentic.yml). Further detail: [agent-redact/README.md](https://github.com/seanpedrick-case/doc_redaction/blob/main/agent-redact/README.md).
|
| 143 |
+
|
| 144 |
+
#### Option 4 - Installation on AWS with CDK
|
| 145 |
+
|
| 146 |
+
The repo contains a [CDK folder](https://github.com/seanpedrick-case/doc_redaction/tree/main/cdk), that contains all the files you need to setup and deploy to an AWS environment with CDK. The installation wizard is [cdk_install.py](https://github.com/seanpedrick-case/doc_redaction/blob/main/cdk/cdk_install.py), which provides a number of options to deploy the Document Redaction App to AWS for demonstration or production. CDK-specific notes (including CloudFront CSP/CORS and the post-deploy refresh deploy) are in [cdk/README.md](cdk/README.md). More details on CDK deployment can be found in the [Installation Guide](https://seanpedrick-case.github.io/doc_redaction/src/installation_guide.html).
|
| 147 |
+
|
| 148 |
+
### 2. Install prerequisites: Tesseract and Poppler
|
| 149 |
+
|
| 150 |
+
This application relies on two external tools for OCR (Tesseract) and PDF processing (Poppler). If not using a Docker-based deployment, you will need to install them on your system before proceeding. To run the Document Redaction app successfully, these tools need to be installed and either 1. added to PATH, or 2. be in a folder that is directly referenced in the config/app_config.env file with the variables TESSERACT_FOLDER and POPPLER_FOLDER (defined [here](https://github.com/seanpedrick-case/doc_redaction/blob/main/tools/config.py) if you want to see the code). The instructions below will guide you through different ways to install these dependencies.
|
| 151 |
+
|
| 152 |
+
---
|
| 153 |
+
|
| 154 |
+
#### Automated dependency setup (recommended)
|
| 155 |
+
|
| 156 |
+
If you **donβt have admin rights** (or you just want the simplest setup), you can have the project download and configure **Tesseract** and **Poppler** into a local `redaction_deps/` folder inside the doc_redaction folder.
|
| 157 |
+
|
| 158 |
+
You need the installer script available first, which means either:
|
| 159 |
+
|
| 160 |
+
- **Repository checkout**: `git clone ...` and run the command from the repo root (recommended for the web UI), or
|
| 161 |
+
- **PyPI install**: `pip install doc_redaction` and run from a writable folder where you want `redaction_deps/` and `config/app_config.env` to be created/updated.
|
| 162 |
+
|
| 163 |
+
From the repository root (or your chosen working folder) after creating/activating your venv and installing Python requirements:
|
| 164 |
+
|
| 165 |
+
```bash
|
| 166 |
+
python -m doc_redaction.install_deps
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
This writes `TESSERACT_FOLDER` / `POPPLER_FOLDER` into `config/app_config.env` so the app can find the binaries without you editing your system PATH.
|
| 170 |
+
|
| 171 |
+
To just check whether your machine can already see the tools:
|
| 172 |
+
|
| 173 |
+
```bash
|
| 174 |
+
python -m doc_redaction.install_deps --verify-only
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
#### **On Windows**
|
| 178 |
+
|
| 179 |
+
If you donβt use the automated setup above, you can install the dependencies manually by downloading installers and adding the programs to your system's PATH.
|
| 180 |
+
|
| 181 |
+
1. **Install Tesseract OCR:**
|
| 182 |
+
* Download the installer from the official Tesseract at [UB Mannheim page](https://github.com/UB-Mannheim/tesseract/wiki) (e.g., `tesseract-ocr-w64-setup-v5.X.X...exe`).
|
| 183 |
+
* Run the installer.
|
| 184 |
+
* **IMPORTANT:** During installation, ensure you select the option to "Add Tesseract to system PATH for all users" or a similar option. This is crucial for the application to find the Tesseract executable.
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
2. **Install Poppler:**
|
| 188 |
+
* Download the latest Poppler binary for Windows. A common source is the [Poppler for Windows](https://github.com/oschwartz10612/poppler-windows) GitHub releases page. Download the `.zip` file (e.g., `poppler-25.07.0-win.zip`).
|
| 189 |
+
* Extract the contents of the zip file to a permanent location on your computer, for example, `C:\Program Files\poppler\`.
|
| 190 |
+
* You must add the `bin` folder from your Poppler installation to your system's PATH environment variable.
|
| 191 |
+
* Search for "Edit the system environment variables" in the Windows Start Menu and open it.
|
| 192 |
+
* Click the "Environment Variables..." button.
|
| 193 |
+
* In the "System variables" section, find and select the `Path` variable, then click "Edit...".
|
| 194 |
+
* Click "New" and add the full path to the `bin` directory inside your Poppler folder (e.g., `C:\Program Files\poppler\poppler-24.02.0\bin`).
|
| 195 |
+
* Click OK on all windows to save the changes.
|
| 196 |
+
|
| 197 |
+
To verify, open a new Command Prompt and run `tesseract --version` and `pdftoppm -v`. If they both return version information, you have successfully installed the prerequisites.
|
| 198 |
+
---
|
| 199 |
+
|
| 200 |
+
#### **On Linux (Debian/Ubuntu)**
|
| 201 |
+
|
| 202 |
+
Open your terminal and run the following command to install Tesseract and Poppler:
|
| 203 |
+
|
| 204 |
+
```bash
|
| 205 |
+
sudo apt-get update && sudo apt-get install -y tesseract-ocr poppler-utils
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
#### **On Linux (Fedora/CentOS/RHEL)**
|
| 209 |
+
|
| 210 |
+
Open your terminal and use the `dnf` or `yum` package manager:
|
| 211 |
+
|
| 212 |
+
```bash
|
| 213 |
+
sudo dnf install -y tesseract poppler-utils
|
| 214 |
+
```
|
| 215 |
+
---
|
| 216 |
+
|
| 217 |
+
### 3. Run the Application
|
| 218 |
+
|
| 219 |
+
With all dependencies installed, you can now start the Gradio application GUI. For a guide on how to use this, please go [here](https://seanpedrick-case.github.io/doc_redaction/src/user_guide.html).
|
| 220 |
+
|
| 221 |
+
```bash
|
| 222 |
+
python app.py
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
After running the command, the application will start, and you will see a local URL in your terminal (usually `http://127.0.0.1:7860`).
|
| 226 |
+
|
| 227 |
+
Open this URL in your web browser to use the document redaction tool
|
| 228 |
+
|
| 229 |
+
#### Command line interface
|
| 230 |
+
|
| 231 |
+
For example CLI commands, please refer to [this guide](https://seanpedrick-case.github.io/doc_redaction/src/user_guide.html#command-line-interface-cli) or the examples in [cli_redact.py](https://github.com/seanpedrick-case/doc_redaction/blob/main/cli_redact.py#L321)
|
| 232 |
+
|
| 233 |
+
If you installed from **PyPI**, use the installed console script:
|
| 234 |
+
|
| 235 |
+
```bash
|
| 236 |
+
cli_redact --help
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
From a **repository checkout**, you can also run:
|
| 240 |
+
|
| 241 |
+
```bash
|
| 242 |
+
python cli_redact.py --help
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
#### Python package commands
|
| 246 |
+
|
| 247 |
+
For Python examples in using the Python package, please see [Python Package usage (Python)](https://seanpedrick-case.github.io/doc_redaction/src/python_package_usage.html).
|
| 248 |
+
|
| 249 |
+
---
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
### 4. βοΈ Configuration (Optional)
|
| 253 |
+
|
| 254 |
+
You can customise the application's behavior by creating a configuration file. This allows you to change settings without modifying the source code, such as enabling AWS features, changing logging behavior, or pointing to local Tesseract/Poppler installations. A full overview of all the potential settings you can modify in the app_config.env file can be seen in tools/config.py, with explanation on the documentation website for [the github repo](https://seanpedrick-case.github.io/doc_redaction/)
|
| 255 |
+
|
| 256 |
+
To get started:
|
| 257 |
+
1. Copy `config/app_config.env.example` to `config/app_config.env`.
|
| 258 |
+
2. Modify the values in `config/app_config.env` to suit your needs. The application will automatically load these settings on startup.
|
| 259 |
+
|
| 260 |
+
If you do not create this file, the application will run with default settings.
|
| 261 |
+
|
| 262 |
+
#### Configuration Breakdown
|
| 263 |
+
|
| 264 |
+
Here is an overview of the most important settings, separated by whether they are for local use or require AWS.
|
| 265 |
+
|
| 266 |
+
---
|
| 267 |
+
|
| 268 |
+
#### **Local & General Settings (No AWS Required)**
|
| 269 |
+
|
| 270 |
+
These settings are useful for all users, regardless of whether you are using AWS.
|
| 271 |
+
|
| 272 |
+
* `TESSERACT_FOLDER` / `POPPLER_FOLDER`
|
| 273 |
+
* Use these if you installed Tesseract or Poppler to a custom location on **Windows** and did not add them to the system PATH.
|
| 274 |
+
* Provide the path to the respective installation folders (for Poppler, point to the `bin` sub-directory).
|
| 275 |
+
* **Examples:** `POPPLER_FOLDER=C:/Program Files/poppler-24.02.0/bin/` `TESSERACT_FOLDER=tesseract/`
|
| 276 |
+
|
| 277 |
+
* `TESSERACT_DATA_FOLDER`
|
| 278 |
+
* If Tesseract runs but you see an error like `Error opening data file ./eng.traineddata` or `Tesseract couldn't load any languages`, this is usually because it can't find the `tessdata/` language files.
|
| 279 |
+
* Set this to the folder that contains `eng.traineddata` (typically a `tessdata` directory).
|
| 280 |
+
* **Examples (Windows):** `TESSERACT_DATA_FOLDER=C:/Program Files/Tesseract-OCR/tessdata`
|
| 281 |
+
|
| 282 |
+
* `SHOW_LANGUAGE_SELECTION=True`
|
| 283 |
+
* Set to `True` to display a language selection dropdown in the UI for OCR processing.
|
| 284 |
+
|
| 285 |
+
* `DEFAULT_LOCAL_OCR_MODEL=tesseract`"
|
| 286 |
+
* Choose the backend for local OCR. Options are `tesseract`, `paddle`, or `hybrid`. "Tesseract" is the default, and is recommended. "hybrid-paddle" is a combination of the two - first pass through the redactions will be done with Tesseract, and then a second pass will be done with PaddleOCR on words with low confidence. "paddle" will only return whole line text extraction, and so will only work for OCR, not redaction.
|
| 287 |
+
|
| 288 |
+
* `SESSION_OUTPUT_FOLDER=False`
|
| 289 |
+
* If `True`, redacted files will be saved in unique subfolders within the `output/` directory for each session.
|
| 290 |
+
|
| 291 |
+
* `DISPLAY_FILE_NAMES_IN_LOGS=False`
|
| 292 |
+
* For privacy, file names are not recorded in usage logs by default. Set to `True` to include them.
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
#### **AWS-Specific Settings**
|
| 297 |
+
|
| 298 |
+
These settings are only relevant if you intend to use AWS services like Textract for OCR and Comprehend for PII detection.
|
| 299 |
+
|
| 300 |
+
* `RUN_AWS_FUNCTIONS=True`
|
| 301 |
+
* **This is the master switch.** You must set this to `True` to enable any AWS functionality. If it is `False`, all other AWS settings will be ignored.
|
| 302 |
+
|
| 303 |
+
* **UI Options:**
|
| 304 |
+
* `SHOW_AWS_TEXT_EXTRACTION_OPTIONS=True`: Adds "AWS Textract" as an option in the text extraction dropdown.
|
| 305 |
+
* `SHOW_AWS_PII_DETECTION_OPTIONS=True`: Adds "AWS Comprehend" as an option in the PII detection dropdown.
|
| 306 |
+
|
| 307 |
+
* **Core AWS Configuration:**
|
| 308 |
+
* `AWS_REGION=example-region`: Set your AWS region (e.g., `us-east-1`).
|
| 309 |
+
* `DOCUMENT_REDACTION_BUCKET=example-bucket`: The name of the S3 bucket the application will use for temporary file storage and processing.
|
| 310 |
+
|
| 311 |
+
* **AWS Logging:**
|
| 312 |
+
* `SAVE_LOGS_TO_DYNAMODB=True`: If enabled, usage and feedback logs will be saved to DynamoDB tables.
|
| 313 |
+
* `ACCESS_LOG_DYNAMODB_TABLE_NAME`, `USAGE_LOG_DYNAMODB_TABLE_NAME`, etc.: Specify the names of your DynamoDB tables for logging.
|
| 314 |
+
|
| 315 |
+
* **Advanced AWS Textract Features:**
|
| 316 |
+
* `SHOW_WHOLE_DOCUMENT_TEXTRACT_CALL_OPTIONS=True`: Enables UI components for large-scale, asynchronous document processing via Textract.
|
| 317 |
+
* `TEXTRACT_WHOLE_DOCUMENT_ANALYSIS_BUCKET=example-bucket-output`: A separate S3 bucket for the final output of asynchronous Textract jobs.
|
| 318 |
+
* `LOAD_PREVIOUS_TEXTRACT_JOBS_S3=True`: If enabled, the app will try to load the status of previously submitted asynchronous jobs from S3.
|
| 319 |
+
|
| 320 |
+
* **Cost Tracking (for internal accounting):**
|
| 321 |
+
* `SHOW_COSTS=True`: Displays an estimated cost for AWS operations. Can be enabled even if AWS functions are off.
|
| 322 |
+
* `GET_COST_CODES=True`: Enables a dropdown for users to select a cost code before running a job.
|
| 323 |
+
* `COST_CODES_PATH=config/cost_codes.csv`: The local path to a CSV file containing your cost codes.
|
| 324 |
+
* `ENFORCE_COST_CODES=True`: Makes selecting a cost code mandatory before starting a redaction.
|
| 325 |
+
|
| 326 |
+
Now you have the app installed, please refer to the [User Guide](https://seanpedrick-case.github.io/doc_redaction/src/user_guide.html) for more information on how to use it for basic and advanced redaction.
|
| 327 |
+
|
| 328 |
+
## For agents (API quickstart)
|
| 329 |
+
|
| 330 |
+
If you are an LLM/agent interacting with this app over HTTP (e.g. Hugging Face Spaces), **do not guess inputs** from the UI. Use the Gradio schema as the source of truth:
|
| 331 |
+
|
| 332 |
+
- **Discover schema**: `GET /gradio_api/info`
|
| 333 |
+
- **Upload files**: `POST /gradio_api/upload` (multipart field `files`) β returns server-internal paths like `/tmp/gradio_tmp/...`
|
| 334 |
+
- **Call**: `POST /gradio_api/call/{api_name}` with body `{"data":[...]}` (argument order must match `/gradio_api/info`)
|
| 335 |
+
- **Poll**: `GET /gradio_api/call/{api_name}/{event_id}` until complete
|
| 336 |
+
- **Download outputs**: `GET /gradio_api/file={path}` (note: some deployments return 403 without session cookies)
|
| 337 |
+
|
| 338 |
+
### Choose the correct route (prefer short `gr.api` endpoints)
|
| 339 |
+
|
| 340 |
+
Fetch `/gradio_api/info` and then prefer the simplest route that exists:
|
| 341 |
+
|
| 342 |
+
- **Apply edited review CSV to a PDF**: `/review_apply`
|
| 343 |
+
- **Redact a PDF/image document**: `/doc_redact` β optional `handwrite_signature_checkbox` for AWS Textract (e.g. `Extract handwriting`, `Extract signatures`)
|
| 344 |
+
- **Summarise a PDF**: `/pdf_summarise`
|
| 345 |
+
- **Redact tabular files (CSV/XLSX/Parquet/DOCX)**: `/tabular_redact`
|
| 346 |
+
|
| 347 |
+
If those endpoints are not present in your deployment, fall back to the long UI-chained routes (`/apply_review_redactions`, `/redact_data`, etc.) and build `data[]` strictly from `/gradio_api/info`.
|
| 348 |
+
|
| 349 |
+
### Common gotchas
|
| 350 |
+
|
| 351 |
+
- **Arity errors** (`needed: N, got: M`) mean you called a session-heavy UI handler with the wrong `data[]`. Prefer the short endpoints above.
|
| 352 |
+
- **`handle_file()` gotcha** (for `gradio_client` users): do **not** wrap server-internal upload paths (e.g. `/tmp/gradio_tmp/...`) with `handle_file()`. Pass them as plain strings.
|
| 353 |
+
- **Container-only outputs**: outputs may be written to container paths (e.g. `/home/user/app/output/`). Plan to download via `file=...` or use a mounted output directory in Docker.
|
| 354 |
+
|
| 355 |
+
### Optional: MCP server
|
| 356 |
+
|
| 357 |
+
If you want external agents to call this app reliably without re-implementing Gradio upload/call/poll/download details, consider an **MCP server** that wraps the main tasks (`redact_document`, `apply_review_redactions`, `redact_tabular`, `summarise_document`) behind a small tool interface. See the [relevant documentation](https://github.com/seanpedrick-case/doc_redaction/blob/main/mcp_doc_redaction/README.md).
|
| 358 |
+
|
| 359 |
+
**Use as a library:** After installing from [PyPI](https://pypi.org/project/doc-redaction/) (`pip install doc_redaction`), you can call the same workflows as the Gradio `api_name` routes from Python. See the documentation: [Python Package usage (Python)](https://seanpedrick-case.github.io/doc_redaction/src/python_package_usage.html).
|
| 360 |
+
|
| 361 |
+
To extract text from documents, the 'Local' options are PikePDF for PDFs with selectable text, and OCR with Tesseract. Use AWS Textract to extract more complex elements e.g. handwriting, signatures, or unclear text. PaddleOCR and VLM support is also provided (see the installation instructions below).
|
| 362 |
+
|
| 363 |
+
For PII identification, 'Local' (based on spaCy) gives good results if you are looking for common names or terms, or a custom list of terms to redact (see Redaction settings). AWS Comprehend gives better results at a small cost.
|
| 364 |
+
|
| 365 |
+
Additional options on the 'Redaction settings' include, the type of information to redact (e.g. people, places), custom terms to include/ exclude from redaction, fuzzy matching, language settings, and whole page redaction. After redaction is complete, you can view and modify suggested redactions on the 'Review redactions' tab to quickly create a final redacted document.
|
| 366 |
+
|
| 367 |
+
NOTE: The app is not 100% accurate, and it will miss some personal information. It is essential that all outputs are reviewed **by a human** before using the final outputs.
|
README_PYPI.md
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Document redaction (doc_redaction)
|
| 2 |
+
|
| 3 |
+
<a href="https://pypi.org/project/doc-redaction/" target="_blank"><img alt="PyPI - Version" src="https://img.shields.io/pypi/v/doc-redaction"></a>
|
| 4 |
+
|
| 5 |
+
Redact personally identifiable information (PII) from documents (PDF, PNG, JPG), Word files (DOCX), or tabular data (XLSX/CSV/Parquet). Please see the [User Guide](https://seanpedrick-case.github.io/doc_redaction/src/user_guide.html) for a full walkthrough of all the features in the app.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## π Quick Start - Installation and first run
|
| 10 |
+
|
| 11 |
+
Follow these instructions to get the document redaction application running on your local machine.
|
| 12 |
+
|
| 13 |
+
### 1. Installation
|
| 14 |
+
|
| 15 |
+
#### Option 1 - Recommended: Install from source repo
|
| 16 |
+
|
| 17 |
+
Clone the repository and install in editable mode:
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
git clone https://github.com/seanpedrick-case/doc_redaction.git
|
| 21 |
+
cd doc_redaction
|
| 22 |
+
pip install -e .
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
##### Install extras (Paddle or Transformers/Torch VLM)
|
| 26 |
+
|
| 27 |
+
To install with PaddleOCR (with a transformers backend as of v2.4.0):
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
pip install -e ".[paddle]"
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
If you want to run VLMs / LLMs with the transformers package:
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
pip install -e ".[vlm]"
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
Note that the versions of both PaddleOCR and Torch installed by default are the CPU-only versions. If you want to install the GPU-enabled version of torch, it is advised to install the following version:
|
| 42 |
+
```bash
|
| 43 |
+
pip install torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu129
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
#### Option 2 - Install from PyPI
|
| 47 |
+
|
| 48 |
+
Create a virtual environment (recommended) and install **doc_redaction**.
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
python -m venv venv
|
| 52 |
+
# Windows:
|
| 53 |
+
.\venv\Scripts\activate
|
| 54 |
+
# macOS/Linux:
|
| 55 |
+
source venv/bin/activate
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
The package is published on PyPI as **`doc-redaction`** (import name **`doc_redaction`**):
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
pip install doc_redaction
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
Optional extras (same as in `pyproject.toml`). For installing paddleOCR:
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
pip install "doc_redaction[paddle]"
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
For running VLMs / LLMs with the transformers package:
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
pip install "doc_redaction[vlm]"
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
For programmatic use (CLI-first API matching Gradio `api_name` routes), see **[Python Package usage (Python)](https://seanpedrick-case.github.io/doc_redaction/src/python_package_usage.html)**. The console script **`cli_redact`** is available after install.
|
| 77 |
+
|
| 78 |
+
**Web UI from a PyPI install:** You *can* start the Gradio UI after `pip install doc_redaction` by running (note that the prerequisites tesseract and poppler will need to be correctly installed following step 2 below):
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
python -m app
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
**Important: your working directory matters.** When you run `python -m app`, the app treats your *current folder* as the βapp folderβ:
|
| 85 |
+
|
| 86 |
+
- It will look for configuration at `config/app_config.env` *relative to the folder you run it from* (and `python -m doc_redaction.install_deps` will also write `config/app_config.env` there).
|
| 87 |
+
- It may create new folders in that location (for example `config/`, `output/`, `input/`, `logs/`, `usage/`, `feedback/`, and temporary/cache folders depending on your settings).
|
| 88 |
+
- The UI example files and bundled assets are packaged with the PyPI install (they live inside the installed `doc_redaction` package). If you run from a βrandomβ directory after a PyPI install, the app can still locate its packaged examples; your working directory mainly affects where `config/`, `input/`, `output/`, logs, and temp folders are created.
|
| 89 |
+
|
| 90 |
+
In practice, the **smoothest UI experience** (examples, bundled assets, docs links, predictable relative paths) is still usually via a **repository checkout** or **Docker**, but PyPI install is sufficient to launch the UI as long as you run it from a suitable working folder and have the system dependencies available (or run `python -m doc_redaction.install_deps` first).
|
| 91 |
+
|
| 92 |
+
#### Option 3 - Docker installation
|
| 93 |
+
|
| 94 |
+
The doc_redaction Redaction app can be installed by using the [Dockerfile](https://github.com/seanpedrick-case/doc_redaction/blob/main/Dockerfile) or Docker compose files ([llama.cpp](https://github.com/ggml-org/llama.cpp), [vLLM](https://docs.vllm.ai/en/stable/)) provided in the repo.
|
| 95 |
+
|
| 96 |
+
##### With Llama.cpp / vLLM inference server
|
| 97 |
+
|
| 98 |
+
The project now has Docker and Docker compose files available to pair running the Redaction app with local inference servers powered by [llama.cpp](https://github.com/ggml-org/llama.cpp), or [vLLM](https://docs.vllm.ai/en/stable/). Llama.cpp is more flexible than vLLM for low VRAM systems, as Llama.cpp will offload to cpu/system RAM automatically rather than failing as vLLM tends to do.
|
| 99 |
+
|
| 100 |
+
For Llama.cpp, you can use the [docker-compose_llama.yml](https://github.com/seanpedrick-case/doc_redaction/blob/main/docker-compose_llama.yml) file, and for vLLM, you can use the [docker-compose_vllm.yml](https://github.com/seanpedrick-case/doc_redaction/blob/main/docker-compose_vllm.yml) file. To run, Docker / Docker Desktop should be installed, and then you can run the commands suggested in the top of the files to run the servers.
|
| 101 |
+
|
| 102 |
+
You will need ~40 GB of disk space to run everything depending on the model chosen from the compose file. For the vLLM server, you will need 24 GB VRAM. For the Llama.cpp server, 24 GB VRAM is needed to run at full speed, but the n-gpu-layers and n-cpu-moe parameters in the Docker compose file can be adjusted to fit into your system. I would suggest that 8 GB VRAM is needed as a bare minimum for decent inference speed. See the [Unsloth guide](https://unsloth.ai/docs/models/qwen3.5) for more details on working with GGUF files for Qwen 3.5.
|
| 103 |
+
|
| 104 |
+
##### Without Llama.cpp / vLLM inference server
|
| 105 |
+
|
| 106 |
+
If you want a working Docker installation without GPU support, you can install from the [Dockerfile](https://github.com/seanpedrick-case/doc_redaction/blob/main/Dockerfile) in the repo. A working example of this, with the CPU version of PaddleOCR, can be found on [Hugging Face](https://huggingface.co/spaces/seanpedrickcase/document_redaction). You can adjust the INSTALL_PADDLEOCR, PADDLE_GPU_ENABLED, INSTALL_VLM, and TORCH_GPU_ENABLED config variables to adjust for PaddleOCR and Transformers packages for local VLM support. Note that GPU-enabled PaddleOCR, and GPU-enabled Transformers/Torch often don't work well together, which is one reason why a Llama.cpp/vLLM inference server Docker installation option is provided below.
|
| 107 |
+
|
| 108 |
+
The main [Dockerfile](https://github.com/seanpedrick-case/doc_redaction/blob/main/Dockerfile) produces two final images via build targets: **`gradio`** (default web UI, non-root user, named volumes for writable paths) and **`lambda`** (AWS Lambda handler). Build examples:
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
docker build -f Dockerfile --target gradio -t doc-redaction-gradio .
|
| 112 |
+
docker build -f Dockerfile --target lambda -t doc-redaction-lambda .
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
##### Pi agent (agentic redaction)
|
| 116 |
+
|
| 117 |
+
The [Pi](https://github.com/earendil-works/pi) orchestration UI uses a separate multi-stage image at [agent-redact/pi-agent/Dockerfile](https://github.com/seanpedrick-case/doc_redaction/blob/main/agent-redact/pi-agent/Dockerfile). It shares the same Python 3.12 slim base as the main app; a small Node stage installs the `pi` CLI, which is copied into the runtime image.
|
| 118 |
+
|
| 119 |
+
| Build target | Typical use |
|
| 120 |
+
|--------------|-------------|
|
| 121 |
+
| **`dev`** | Local development with [docker-compose_llama_agentic.yml](https://github.com/seanpedrick-case/doc_redaction/blob/main/docker-compose_llama_agentic.yml) β the repo is bind-mounted; only Pi CLI + Python deps are in the image. |
|
| 122 |
+
| **`runtime`** | [Hugging Face Space](https://huggingface.co/spaces/seanpedrickcase/agentic_document_redaction) and AWS ECS β agent code is baked in; runs as non-root `user` with **named volumes** for workspace, uploads, and session dirs (read-only root filesystem friendly). |
|
| 123 |
+
|
| 124 |
+
Build from the repository root:
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
docker build -f agent-redact/pi-agent/Dockerfile --target dev -t pi-agent-dev .
|
| 128 |
+
docker build -f agent-redact/pi-agent/Dockerfile --target runtime -t pi-agent-runtime .
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
For llama.cpp + Pi together, see the compose examples at the top of [docker-compose_llama_agentic.yml](https://github.com/seanpedrick-case/doc_redaction/blob/main/docker-compose_llama_agentic.yml). Further detail: [agent-redact/README.md](https://github.com/seanpedrick-case/doc_redaction/blob/main/agent-redact/README.md).
|
| 132 |
+
|
| 133 |
+
#### Option 4 - Installation on AWS with CDK
|
| 134 |
+
|
| 135 |
+
The repo contains a [CDK folder](https://github.com/seanpedrick-case/doc_redaction/tree/main/cdk), that contains all the files you need to setup and deploy to an AWS environment with CDK. The installation wizard is [cdk_install.py](https://github.com/seanpedrick-case/doc_redaction/blob/main/cdk/cdk_install.py), which provides a number of options to deploy the Document Redaction App to AWS for demonstration or production. More details on CDK deployment can be found in the [Installation Guide](https://seanpedrick-case.github.io/doc_redaction/src/installation_guide.html).
|
| 136 |
+
|
| 137 |
+
### 2. Install prerequisites: Tesseract and Poppler
|
| 138 |
+
|
| 139 |
+
This application relies on two external tools for OCR (Tesseract) and PDF processing (Poppler). Please install them on your system before proceeding.
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
#### Automated dependency setup (recommended)
|
| 144 |
+
|
| 145 |
+
If you **donβt have admin rights** (or you just want the simplest setup), you can have the project download and configure **Tesseract** and **Poppler** into a local `redaction_deps/` folder inside the doc_redaction folder.
|
| 146 |
+
|
| 147 |
+
You need the installer script available first, which means either:
|
| 148 |
+
|
| 149 |
+
- **Repository checkout**: `git clone ...` and run the command from the repo root (recommended for the web UI), or
|
| 150 |
+
- **PyPI install**: `pip install doc_redaction` and run from a writable folder where you want `redaction_deps/` and `config/app_config.env` to be created/updated.
|
| 151 |
+
|
| 152 |
+
From the repository root (or your chosen working folder) after creating/activating your venv and installing Python requirements:
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
python -m doc_redaction.install_deps
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
This writes `TESSERACT_FOLDER` / `POPPLER_FOLDER` into `config/app_config.env` so the app can find the binaries without you editing your system PATH.
|
| 159 |
+
|
| 160 |
+
To just check whether your machine can already see the tools:
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
python -m doc_redaction.install_deps --verify-only
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
#### **On Windows**
|
| 167 |
+
|
| 168 |
+
If you donβt use the automated setup above, you can install the dependencies manually by downloading installers and adding the programs to your system's PATH.
|
| 169 |
+
|
| 170 |
+
1. **Install Tesseract OCR:**
|
| 171 |
+
* Download the installer from the official Tesseract at [UB Mannheim page](https://github.com/UB-Mannheim/tesseract/wiki) (e.g., `tesseract-ocr-w64-setup-v5.X.X...exe`).
|
| 172 |
+
* Run the installer.
|
| 173 |
+
* **IMPORTANT:** During installation, ensure you select the option to "Add Tesseract to system PATH for all users" or a similar option. This is crucial for the application to find the Tesseract executable.
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
2. **Install Poppler:**
|
| 177 |
+
* Download the latest Poppler binary for Windows. A common source is the [Poppler for Windows](https://github.com/oschwartz10612/poppler-windows) GitHub releases page. Download the `.zip` file (e.g., `poppler-25.07.0-win.zip`).
|
| 178 |
+
* Extract the contents of the zip file to a permanent location on your computer, for example, `C:\Program Files\poppler\`.
|
| 179 |
+
* You must add the `bin` folder from your Poppler installation to your system's PATH environment variable.
|
| 180 |
+
* Search for "Edit the system environment variables" in the Windows Start Menu and open it.
|
| 181 |
+
* Click the "Environment Variables..." button.
|
| 182 |
+
* In the "System variables" section, find and select the `Path` variable, then click "Edit...".
|
| 183 |
+
* Click "New" and add the full path to the `bin` directory inside your Poppler folder (e.g., `C:\Program Files\poppler\poppler-24.02.0\bin`).
|
| 184 |
+
* Click OK on all windows to save the changes.
|
| 185 |
+
|
| 186 |
+
To verify, open a new Command Prompt and run `tesseract --version` and `pdftoppm -v`. If they both return version information, you have successfully installed the prerequisites.
|
| 187 |
+
---
|
| 188 |
+
|
| 189 |
+
#### **On Linux (Debian/Ubuntu)**
|
| 190 |
+
|
| 191 |
+
Open your terminal and run the following command to install Tesseract and Poppler:
|
| 192 |
+
|
| 193 |
+
```bash
|
| 194 |
+
sudo apt-get update && sudo apt-get install -y tesseract-ocr poppler-utils
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
#### **On Linux (Fedora/CentOS/RHEL)**
|
| 198 |
+
|
| 199 |
+
Open your terminal and use the `dnf` or `yum` package manager:
|
| 200 |
+
|
| 201 |
+
```bash
|
| 202 |
+
sudo dnf install -y tesseract poppler-utils
|
| 203 |
+
```
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
### 3. Run the Application
|
| 207 |
+
|
| 208 |
+
With all dependencies installed, you can now start the Gradio application GUI. For a guide on how to use this, please go [here](https://seanpedrick-case.github.io/doc_redaction/src/user_guide.html).
|
| 209 |
+
|
| 210 |
+
```bash
|
| 211 |
+
python app.py
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
After running the command, the application will start, and you will see a local URL in your terminal (usually `http://127.0.0.1:7860`).
|
| 215 |
+
|
| 216 |
+
Open this URL in your web browser to use the document redaction tool
|
| 217 |
+
|
| 218 |
+
#### Command line interface
|
| 219 |
+
|
| 220 |
+
For example CLI commands, please refer to [this guide](https://seanpedrick-case.github.io/doc_redaction/src/user_guide.html#command-line-interface-cli) or the examples in [cli_redact.py](https://github.com/seanpedrick-case/doc_redaction/blob/main/cli_redact.py#L321)
|
| 221 |
+
|
| 222 |
+
If you installed from **PyPI**, use the installed console script:
|
| 223 |
+
|
| 224 |
+
```bash
|
| 225 |
+
cli_redact --help
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
From a **repository checkout**, you can also run:
|
| 229 |
+
|
| 230 |
+
```bash
|
| 231 |
+
python cli_redact.py --help
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
#### Python package commands
|
| 235 |
+
|
| 236 |
+
For Python examples in using the Python package, please see [Python Package usage (Python)](https://seanpedrick-case.github.io/doc_redaction/src/python_package_usage.html).
|
| 237 |
+
|
| 238 |
+
---
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
### 4. βοΈ Configuration (Optional)
|
| 242 |
+
|
| 243 |
+
You can customise the application's behavior by creating a configuration file. This allows you to change settings without modifying the source code, such as enabling AWS features, changing logging behavior, or pointing to local Tesseract/Poppler installations. A full overview of all the potential settings you can modify in the app_config.env file can be seen in tools/config.py, with explanation on the documentation website for [the github repo](https://seanpedrick-case.github.io/doc_redaction/)
|
| 244 |
+
|
| 245 |
+
To get started:
|
| 246 |
+
1. Copy `config/app_config.env.example` to `config/app_config.env`.
|
| 247 |
+
2. Modify the values in `config/app_config.env` to suit your needs. The application will automatically load these settings on startup.
|
| 248 |
+
|
| 249 |
+
If you do not create this file, the application will run with default settings.
|
| 250 |
+
|
| 251 |
+
#### Configuration Breakdown
|
| 252 |
+
|
| 253 |
+
Here is an overview of the most important settings, separated by whether they are for local use or require AWS.
|
| 254 |
+
|
| 255 |
+
---
|
| 256 |
+
|
| 257 |
+
#### **Local & General Settings (No AWS Required)**
|
| 258 |
+
|
| 259 |
+
These settings are useful for all users, regardless of whether you are using AWS.
|
| 260 |
+
|
| 261 |
+
* `TESSERACT_FOLDER` / `POPPLER_FOLDER`
|
| 262 |
+
* Use these if you installed Tesseract or Poppler to a custom location on **Windows** and did not add them to the system PATH.
|
| 263 |
+
* Provide the path to the respective installation folders (for Poppler, point to the `bin` sub-directory).
|
| 264 |
+
* **Examples:** `POPPLER_FOLDER=C:/Program Files/poppler-24.02.0/bin/` `TESSERACT_FOLDER=tesseract/`
|
| 265 |
+
|
| 266 |
+
* `SHOW_LANGUAGE_SELECTION=True`
|
| 267 |
+
* Set to `True` to display a language selection dropdown in the UI for OCR processing.
|
| 268 |
+
|
| 269 |
+
* `DEFAULT_LOCAL_OCR_MODEL=tesseract`"
|
| 270 |
+
* Choose the backend for local OCR. Options are `tesseract`, `paddle`, or `hybrid`. "Tesseract" is the default, and is recommended. "hybrid-paddle" is a combination of the two - first pass through the redactions will be done with Tesseract, and then a second pass will be done with PaddleOCR on words with low confidence. "paddle" will only return whole line text extraction, and so will only work for OCR, not redaction.
|
| 271 |
+
|
| 272 |
+
* `SESSION_OUTPUT_FOLDER=False`
|
| 273 |
+
* If `True`, redacted files will be saved in unique subfolders within the `output/` directory for each session.
|
| 274 |
+
|
| 275 |
+
* `DISPLAY_FILE_NAMES_IN_LOGS=False`
|
| 276 |
+
* For privacy, file names are not recorded in usage logs by default. Set to `True` to include them.
|
| 277 |
+
|
| 278 |
+
---
|
| 279 |
+
|
| 280 |
+
#### **AWS-Specific Settings**
|
| 281 |
+
|
| 282 |
+
These settings are only relevant if you intend to use AWS services like Textract for OCR and Comprehend for PII detection.
|
| 283 |
+
|
| 284 |
+
* `RUN_AWS_FUNCTIONS=True`
|
| 285 |
+
* **This is the master switch.** You must set this to `True` to enable any AWS functionality. If it is `False`, all other AWS settings will be ignored.
|
| 286 |
+
|
| 287 |
+
* **UI Options:**
|
| 288 |
+
* `SHOW_AWS_TEXT_EXTRACTION_OPTIONS=True`: Adds "AWS Textract" as an option in the text extraction dropdown.
|
| 289 |
+
* `SHOW_AWS_PII_DETECTION_OPTIONS=True`: Adds "AWS Comprehend" as an option in the PII detection dropdown.
|
| 290 |
+
|
| 291 |
+
* **Core AWS Configuration:**
|
| 292 |
+
* `AWS_REGION=example-region`: Set your AWS region (e.g., `us-east-1`).
|
| 293 |
+
* `DOCUMENT_REDACTION_BUCKET=example-bucket`: The name of the S3 bucket the application will use for temporary file storage and processing.
|
| 294 |
+
|
| 295 |
+
* **AWS Logging:**
|
| 296 |
+
* `SAVE_LOGS_TO_DYNAMODB=True`: If enabled, usage and feedback logs will be saved to DynamoDB tables.
|
| 297 |
+
* `ACCESS_LOG_DYNAMODB_TABLE_NAME`, `USAGE_LOG_DYNAMODB_TABLE_NAME`, etc.: Specify the names of your DynamoDB tables for logging.
|
| 298 |
+
|
| 299 |
+
* **Advanced AWS Textract Features:**
|
| 300 |
+
* `SHOW_WHOLE_DOCUMENT_TEXTRACT_CALL_OPTIONS=True`: Enables UI components for large-scale, asynchronous document processing via Textract.
|
| 301 |
+
* `TEXTRACT_WHOLE_DOCUMENT_ANALYSIS_BUCKET=example-bucket-output`: A separate S3 bucket for the final output of asynchronous Textract jobs.
|
| 302 |
+
* `LOAD_PREVIOUS_TEXTRACT_JOBS_S3=True`: If enabled, the app will try to load the status of previously submitted asynchronous jobs from S3.
|
| 303 |
+
|
| 304 |
+
* **Cost Tracking (for internal accounting):**
|
| 305 |
+
* `SHOW_COSTS=True`: Displays an estimated cost for AWS operations. Can be enabled even if AWS functions are off.
|
| 306 |
+
* `GET_COST_CODES=True`: Enables a dropdown for users to select a cost code before running a job.
|
| 307 |
+
* `COST_CODES_PATH=config/cost_codes.csv`: The local path to a CSV file containing your cost codes.
|
| 308 |
+
* `ENFORCE_COST_CODES=True`: Makes selecting a cost code mandatory before starting a redaction.
|
| 309 |
+
|
| 310 |
+
Now you have the app installed, please refer to the [User Guide](https://seanpedrick-case.github.io/doc_redaction/src/user_guide.html) for more information on how to use it for basic and advanced redaction.
|
| 311 |
+
|
| 312 |
+
## For agents (API quickstart)
|
| 313 |
+
|
| 314 |
+
If you are an LLM/agent interacting with this app over HTTP (e.g. Hugging Face Spaces), **do not guess inputs** from the UI. Use the Gradio schema as the source of truth:
|
| 315 |
+
|
| 316 |
+
- **Discover schema**: `GET /gradio_api/info`
|
| 317 |
+
- **Upload files**: `POST /gradio_api/upload` (multipart field `files`) β returns server-internal paths like `/tmp/gradio_tmp/...`
|
| 318 |
+
- **Call**: `POST /gradio_api/call/{api_name}` with body `{"data":[...]}` (argument order must match `/gradio_api/info`)
|
| 319 |
+
- **Poll**: `GET /gradio_api/call/{api_name}/{event_id}` until complete
|
| 320 |
+
- **Download outputs**: `GET /gradio_api/file={path}` (note: some deployments return 403 without session cookies)
|
| 321 |
+
|
| 322 |
+
### Choose the correct route (prefer short `gr.api` endpoints)
|
| 323 |
+
|
| 324 |
+
Fetch `/gradio_api/info` and then prefer the simplest route that exists:
|
| 325 |
+
|
| 326 |
+
- **Apply edited review CSV to a PDF**: `/review_apply`
|
| 327 |
+
- **Redact a PDF/image document**: `/doc_redact` β optional `handwrite_signature_checkbox` for AWS Textract (e.g. `Extract handwriting`, `Extract signatures`)
|
| 328 |
+
- **Summarise a PDF**: `/pdf_summarise`
|
| 329 |
+
- **Redact tabular files (CSV/XLSX/Parquet/DOCX)**: `/tabular_redact`
|
| 330 |
+
|
| 331 |
+
If those endpoints are not present in your deployment, fall back to the long UI-chained routes (`/apply_review_redactions`, `/redact_data`, etc.) and build `data[]` strictly from `/gradio_api/info`.
|
| 332 |
+
|
| 333 |
+
### Common gotchas
|
| 334 |
+
|
| 335 |
+
- **Arity errors** (`needed: N, got: M`) mean you called a session-heavy UI handler with the wrong `data[]`. Prefer the short endpoints above.
|
| 336 |
+
- **`handle_file()` gotcha** (for `gradio_client` users): do **not** wrap server-internal upload paths (e.g. `/tmp/gradio_tmp/...`) with `handle_file()`. Pass them as plain strings.
|
| 337 |
+
- **Container-only outputs**: outputs may be written to container paths (e.g. `/home/user/app/output/`). Plan to download via `file=...` or use a mounted output directory in Docker.
|
| 338 |
+
|
| 339 |
+
### Optional: MCP server
|
| 340 |
+
|
| 341 |
+
If you want external agents to call this app reliably without re-implementing Gradio upload/call/poll/download details, consider an **MCP server** that wraps the main tasks (`redact_document`, `apply_review_redactions`, `redact_tabular`, `summarise_document`) behind a small tool interface. See the [relevant documentation](https://github.com/seanpedrick-case/doc_redaction/blob/main/mcp_doc_redaction/README.md).
|
| 342 |
+
|
| 343 |
+
**Use as a library:** After installing from [PyPI](https://pypi.org/project/doc-redaction/) (`pip install doc_redaction`), you can call the same workflows as the Gradio `api_name` routes from Python. See the documentation: [Python Package usage (Python)](https://seanpedrick-case.github.io/doc_redaction/src/python_package_usage.html).
|
| 344 |
+
|
| 345 |
+
To extract text from documents, the 'Local' options are PikePDF for PDFs with selectable text, and OCR with Tesseract. Use AWS Textract to extract more complex elements e.g. handwriting, signatures, or unclear text. PaddleOCR and VLM support is also provided (see the installation instructions below).
|
| 346 |
+
|
| 347 |
+
For PII identification, 'Local' (based on spaCy) gives good results if you are looking for common names or terms, or a custom list of terms to redact (see Redaction settings). AWS Comprehend gives better results at a small cost.
|
| 348 |
+
|
| 349 |
+
Additional options on the 'Redaction settings' include, the type of information to redact (e.g. people, places), custom terms to include/ exclude from redaction, fuzzy matching, language settings, and whole page redaction. After redaction is complete, you can view and modify suggested redactions on the 'Review redactions' tab to quickly create a final redacted document.
|
| 350 |
+
|
| 351 |
+
NOTE: The app is not 100% accurate, and it will miss some personal information. It is essential that all outputs are reviewed **by a human** before using the final outputs.
|
agent-redact/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pi-based agentic document redaction: local Docker orchestration and Hugging Face Space packaging.
|
| 2 |
+
|
| 3 |
+
Supports three orchestration backends via `AGENT_ORCHESTRATOR` (`pi` default, `langgraph`, `agentcore`). See [`pi/agent/README.md`](pi/agent/README.md).
|
| 4 |
+
|
| 5 |
+
| Path | Purpose |
|
| 6 |
+
|------|---------|
|
| 7 |
+
| [`shared/`](shared/) | Gradio UI, runtime factory, session workspace, remote redaction helpers |
|
| 8 |
+
| [`pi/`](pi/) | Pi RPC client, Pi config generation, skills sync, `agent/*.json` templates |
|
| 9 |
+
| [`redaction_langgraph/`](redaction_langgraph/) | LangGraph ReAct agent (curated tools, no shell) |
|
| 10 |
+
| [`agentcore/`](agentcore/) | Bedrock AgentCore runtime + Gradio AgentCore clients + **[install guide](agentcore/README.md)** |
|
| 11 |
+
| [`eval/`](eval/) | Arize AX / Phoenix tracing for LangGraph |
|
| 12 |
+
| [`pi-agent/`](pi-agent/) | Docker image (`dev` + `runtime` targets), sync script, and manifest |
|
| 13 |
+
| [`requirements_agent.txt`](requirements_agent.txt) | Python deps for agent-redact Docker images |
|
| 14 |
+
|
| 15 |
+
Per-user output isolation uses Gradio `session_hash` subfolders under `AGENT_WORKSPACE_DIR` (see `agent-redact/shared/session_workspace.py`). Enabled by default locally and on HF Spaces. Set `AGENT_SESSION_WORKSPACE=false` only if you want one shared workspace tree for all sessions.
|
| 16 |
+
|
| 17 |
+
## Local Docker
|
| 18 |
+
|
| 19 |
+
Use the `pi-agent` service in [`docker-compose_llama_agentic.yml`](../docker-compose_llama_agentic.yml) (profile `27b_36`). See [`pi/agent/README.md`](pi/agent/README.md).
|
| 20 |
+
|
| 21 |
+
## Hugging Face Space
|
| 22 |
+
|
| 23 |
+
Build from repo root:
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
# Production (HF Space / ECS)
|
| 27 |
+
docker build -f agent-redact/pi-agent/Dockerfile --target runtime .
|
| 28 |
+
|
| 29 |
+
# Local compose (bind-mounted repo)
|
| 30 |
+
docker build -f agent-redact/pi-agent/Dockerfile --target dev .
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Sync to Space on pushes to `dev` via [`.github/workflows/sync-pi-agent-space.yml`](../.github/workflows/sync-pi-agent-space.yml).
|
agent-redact/agentcore/Dockerfile.runtime
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
# Bedrock AgentCore Runtime image (linux/arm64). Build from monorepo root:
|
| 3 |
+
# docker build --platform linux/arm64 -f agent-redact/agentcore/Dockerfile.runtime .
|
| 4 |
+
#
|
| 5 |
+
# AgentCore requires arm64, port 8080, POST /invocations and GET /ping
|
| 6 |
+
# (provided by BedrockAgentCoreApp in entrypoint.py).
|
| 7 |
+
|
| 8 |
+
FROM --platform=linux/arm64 public.ecr.aws/docker/library/python:3.12.13-slim-trixie
|
| 9 |
+
|
| 10 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 11 |
+
ENV PYTHONUNBUFFERED=1
|
| 12 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 13 |
+
ENV APP_TYPE=agent
|
| 14 |
+
ENV APP_HOME=/app
|
| 15 |
+
ENV PYTHONPATH=${APP_HOME}:${APP_HOME}/agent-redact:${APP_HOME}/agent-redact/shared:${APP_HOME}/agent-redact/pi:${APP_HOME}/agent-redact/agentcore
|
| 16 |
+
ENV AGENT_WORKSPACE_DIR=/tmp/agentcore-workspace
|
| 17 |
+
ENV AWS_REGION=eu-west-2
|
| 18 |
+
ENV AWS_DEFAULT_REGION=${AWS_REGION}
|
| 19 |
+
|
| 20 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 21 |
+
ca-certificates \
|
| 22 |
+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 23 |
+
|
| 24 |
+
WORKDIR ${APP_HOME}
|
| 25 |
+
|
| 26 |
+
COPY agent-redact/requirements_agent.txt /tmp/requirements_agent.txt
|
| 27 |
+
RUN pip install --no-cache-dir -r /tmp/requirements_agent.txt \
|
| 28 |
+
&& rm /tmp/requirements_agent.txt
|
| 29 |
+
|
| 30 |
+
COPY agent-redact/ ${APP_HOME}/agent-redact/
|
| 31 |
+
COPY config/agent.env.example ${APP_HOME}/config/agent.env.example
|
| 32 |
+
|
| 33 |
+
RUN mkdir -p /tmp/agentcore-workspace \
|
| 34 |
+
&& chmod 1777 /tmp/agentcore-workspace
|
| 35 |
+
|
| 36 |
+
EXPOSE 8080
|
| 37 |
+
|
| 38 |
+
CMD ["python", "agent-redact/agentcore/entrypoint.py"]
|
agent-redact/agentcore/README.md
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Bedrock AgentCore install guide
|
| 2 |
+
|
| 3 |
+
This folder contains the **AgentCore Runtime entrypoint** ([`entrypoint.py`](entrypoint.py)) β a LangGraph redaction agent wrapped in `BedrockAgentCoreApp`.
|
| 4 |
+
|
| 5 |
+
The **Gradio agent UI** (Pi Express / legacy ECS from [`cdk/cdk_install.py`](../../cdk/cdk_install.py)) stays the user-facing app. When `AGENT_ORCHESTRATOR=agentcore`, that UI proxies prompts to a **separately deployed** AgentCore Runtime via `AGENTCORE_RUNTIME_URL`.
|
| 6 |
+
|
| 7 |
+
You do **not** define `AGENTCORE_RUNTIME_URL` manually in the AWS console beforehand. It is the **invoke endpoint AWS returns after you deploy** an AgentCore Runtime.
|
| 8 |
+
|
| 9 |
+
## Architecture (two parts)
|
| 10 |
+
|
| 11 |
+
| Component | Deployed by | Role |
|
| 12 |
+
|-----------|-------------|------|
|
| 13 |
+
| **AgentCore Runtime** | [AgentCore CLI](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-get-started-cli.html) (`agentcore deploy`) | Runs the LangGraph agent (`entrypoint.py`) |
|
| 14 |
+
| **Gradio agent UI** | doc_redaction CDK / `cdk_install.py` (Pi Express or legacy) | Browser UI; streams to AgentCore when `AGENT_ORCHESTRATOR=agentcore` or `agentcore-harness` |
|
| 15 |
+
|
| 16 |
+
The main **doc_redaction** app (OCR, PII, `/doc_redact`, `/review_apply`) is unchanged.
|
| 17 |
+
|
| 18 |
+
- **Pi / LangGraph in the Pi Express container** can call the main app over ECS Service Connect (`http://redaction:7860`).
|
| 19 |
+
- **Bedrock AgentCore Runtime** (separate AWS service) uses the **main Express public HTTPS URL** (`ExpressServiceEndpoint` stack output). CDK sets that on Pi Express when `ENABLE_AGENTCORE_RUNTIME=True`; Gradio passes it to AgentCore on each invoke via `runtime_config`.
|
| 20 |
+
|
| 21 |
+
## Runtime vs Harness
|
| 22 |
+
|
| 23 |
+
| | **AgentCore Runtime** (`agentcore`) | **AgentCore Harness** (`agentcore-harness`) |
|
| 24 |
+
|--|-------------------------------------|---------------------------------------------|
|
| 25 |
+
| AWS resource | `arn:...:runtime/...` | `arn:...:harness/...` |
|
| 26 |
+
| Config | `AGENTCORE_RUNTIME_URL` (HTTP base, no `/invocations`) | `AGENTCORE_HARNESS_ARN` |
|
| 27 |
+
| Invoke | `InvokeAgentRuntime` / HTTP SSE | `InvokeHarness` (boto3 stream) |
|
| 28 |
+
| Agent code | Your LangGraph bundle (`package_runtime.py`) | AWS-managed Strands loop; tools/skills in console |
|
| 29 |
+
| Redaction prompt | Tool orchestrator (curated LangGraph tools) | Pi-like partnership prompt (skills + shell) |
|
| 30 |
+
| File upload from Gradio | Base64 `workspace_files` in invoke payload | S3 presigned URL prefix (`AGENTCORE_HARNESS_S3_INPUT_PREFIX`) |
|
| 31 |
+
|
| 32 |
+
There is **no HTTP invocation URL** for a Harness β configure the **ARN** and call the SDK. The console does not show a Runtime-style URL for Harness resources.
|
| 33 |
+
|
| 34 |
+
## What `AGENTCORE_RUNTIME_URL` is
|
| 35 |
+
|
| 36 |
+
In this repo, set the **base URL only** (no trailing slash, **no** `/invocations` suffix). The Gradio client in [`agentcore_runtime.py`](agentcore_runtime.py) calls:
|
| 37 |
+
|
| 38 |
+
```text
|
| 39 |
+
{AGENTCORE_RUNTIME_URL}/invocations
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Example base (region and ARN are yours):
|
| 43 |
+
|
| 44 |
+
```text
|
| 45 |
+
https://bedrock-agentcore.eu-west-2.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aeu-west-2%3A123456789012%3Aruntime%2FRedactionAgent
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
Full invoke URLs often include `?qualifier=DEFAULT`; this project appends `/invocations` to the base you configure.
|
| 49 |
+
|
| 50 |
+
## Prerequisites
|
| 51 |
+
|
| 52 |
+
- AWS account with credentials configured (`aws sts get-caller-identity`)
|
| 53 |
+
- [Node.js 20+](https://nodejs.org/) for the AgentCore CLI
|
| 54 |
+
- Python 3.10+
|
| 55 |
+
- [AWS CDK bootstrapped](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) in the target account/region (`cdk bootstrap`)
|
| 56 |
+
- Bedrock model access enabled if the agent uses Bedrock models
|
| 57 |
+
- IAM permissions for AgentCore deploy and `bedrock-agentcore:InvokeAgentRuntime` (see [Use the AgentCore CLI](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-get-started-cli.html))
|
| 58 |
+
|
| 59 |
+
Install the CLI:
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
npm install -g @aws/agentcore
|
| 63 |
+
agentcore --help
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
If you previously installed the old Python βstarter toolkitβ CLI, uninstall it first β both use the `agentcore` command name (`pip uninstall bedrock-agentcore-starter-toolkit` if applicable).
|
| 67 |
+
|
| 68 |
+
## Step 1 β Deploy the AgentCore runtime
|
| 69 |
+
|
| 70 |
+
### Option A: New AgentCore project (recommended first time)
|
| 71 |
+
|
| 72 |
+
The AgentCore CLI creates a **new project folder** next to where you run the command. It does **not** add `app/RedactionAgent/` inside `doc_redaction/agent-redact/agentcore/`.
|
| 73 |
+
|
| 74 |
+
**Run `create` from a parent directory** (repo root, `agent-redact/`, or `~/projects`):
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
# Code-based LangGraph agent (do NOT use --defaults β that creates a harness-only project)
|
| 78 |
+
agentcore create \
|
| 79 |
+
--name RedactionAgent \
|
| 80 |
+
--framework LangChain_LangGraph \
|
| 81 |
+
--model-provider Bedrock \
|
| 82 |
+
--memory none
|
| 83 |
+
|
| 84 |
+
# Or interactive (choose "Agent", then LangChain/LangGraph, Bedrock, memory none):
|
| 85 |
+
# agentcore create
|
| 86 |
+
|
| 87 |
+
cd RedactionAgent
|
| 88 |
+
ls app/RedactionAgent
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
**Why `RedactionAgent/` might not appear**
|
| 92 |
+
|
| 93 |
+
| What you ran | What happened |
|
| 94 |
+
|--------------|----------------|
|
| 95 |
+
| `... --framework LangChain_LangGraph --defaults` | **Invalid combo.** `--defaults` means βcreate a **harness** projectβ, not βfill in missing flagsβ. The CLI exits after: *Use --no-agent for project-only, or provide all: --framework, --model-provider, --memory* β **no folder is created.** |
|
| 96 |
+
| Missing `--model-provider` / `--memory` | Same message; add both flags (see command above). |
|
| 97 |
+
|
| 98 |
+
Non-interactive **code agent** requires all three: `--framework`, `--model-provider`, `--memory`. See `agentcore create --help`.
|
| 99 |
+
|
| 100 |
+
Optional: `--output-dir ..` to create the project next to `agent-redact/` instead of inside it.
|
| 101 |
+
|
| 102 |
+
Expected layout after `create` (project name = `--name` value):
|
| 103 |
+
|
| 104 |
+
```text
|
| 105 |
+
RedactionAgent/
|
| 106 |
+
βββ agentcore/
|
| 107 |
+
β βββ agentcore.json # created by CLI β agent/runtime config
|
| 108 |
+
β βββ aws-targets.json # created by CLI β edit account/region (see below)
|
| 109 |
+
β βββ cdk/ # auto-managed CDK for deploy
|
| 110 |
+
βββ app/
|
| 111 |
+
βββ RedactionAgent/ # same name as --name
|
| 112 |
+
βββ main.py # generated entrypoint β you edit this
|
| 113 |
+
βββ pyproject.toml
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
If you do **not** see `RedactionAgent/` (or `app/<name>/` after `cd`):
|
| 117 |
+
|
| 118 |
+
| Symptom | Likely cause |
|
| 119 |
+
|---------|----------------|
|
| 120 |
+
| No new folder at all | Incomplete non-interactive flags, or used `--defaults` with `--framework` β use full command above |
|
| 121 |
+
| Only `agentcore/` files where you ran the command | You may have run `create` inside an existing AgentCore tree; run it from a clean parent directory instead |
|
| 122 |
+
| No `app/` subdirectory | Interactive wizard chose **Harness** or **Skip** β run `agentcore add agent` or recreate with `--framework LangChain_LangGraph` |
|
| 123 |
+
| Looking for `app/RedactionAgent` inside `agent-redact/agentcore/` | Wrong place β that folder only holds this repoβs reference [`entrypoint.py`](entrypoint.py), not the CLI scaffold |
|
| 124 |
+
|
| 125 |
+
Official reference: [AgentCore get started](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-get-started-cli.html).
|
| 126 |
+
|
| 127 |
+
#### Using this repoβs `entrypoint.py`
|
| 128 |
+
|
| 129 |
+
Do **not** only rename `entrypoint.py` to `main.py`. The CLI already generates `app/RedactionAgent/main.py` with starter LangGraph code.
|
| 130 |
+
|
| 131 |
+
1. Open the generated `app/RedactionAgent/main.py`.
|
| 132 |
+
2. Replace its handler logic with the code from [`entrypoint.py`](entrypoint.py) in this repo (the `@app.entrypoint` async `handler` and `BedrockAgentCoreApp` setup).
|
| 133 |
+
3. Add dependencies to `app/RedactionAgent/pyproject.toml` (e.g. `bedrock-agentcore`, `langgraph`, `langchain-*`) matching [`requirements_agent.txt`](../requirements_agent.txt).
|
| 134 |
+
4. **Package monorepo code for deploy** β the generated project does not automatically include `redaction_langgraph/`, `tools/`, or `skills/`. Typical approaches:
|
| 135 |
+
- **Container build** (`--build Container`): copy or mount the needed paths from `doc_redaction` in the Dockerfile the CLI scaffolds; or
|
| 136 |
+
- **Vendor** `agent-redact/redaction_langgraph/` and required `tools/` modules into `app/RedactionAgent/` before deploy.
|
| 137 |
+
|
| 138 |
+
[`entrypoint.py`](entrypoint.py) is a **reference implementation** for this monorepo; AgentCore deploy packages whatever is under `app/<AgentName>/`, not the whole `doc_redaction` tree unless you wire that in.
|
| 139 |
+
|
| 140 |
+
#### `aws-targets.json` (created for you β edit, donβt invent)
|
| 141 |
+
|
| 142 |
+
You do **not** need to create this file manually. `agentcore create` writes `agentcore/aws-targets.json` inside the new project.
|
| 143 |
+
|
| 144 |
+
Edit it so `account` and `region` match where you will deploy (often `us-west-2` or `eu-west-2` for AgentCore):
|
| 145 |
+
|
| 146 |
+
```json
|
| 147 |
+
[
|
| 148 |
+
{
|
| 149 |
+
"name": "default",
|
| 150 |
+
"description": "doc_redaction AgentCore deploy",
|
| 151 |
+
"account": "123456789012",
|
| 152 |
+
"region": "eu-west-2"
|
| 153 |
+
}
|
| 154 |
+
]
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
Get your account ID: `aws sts get-caller-identity --query Account --output text`.
|
| 158 |
+
|
| 159 |
+
Schema reference: [agentcore-cli configuration](https://github.com/aws/agentcore-cli/blob/main/docs/configuration.md).
|
| 160 |
+
|
| 161 |
+
Then deploy:
|
| 162 |
+
|
| 163 |
+
```bash
|
| 164 |
+
# still inside RedactionAgent/
|
| 165 |
+
agentcore dev # optional: local test at http://localhost:8080/invocations
|
| 166 |
+
agentcore deploy
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
### Option B: Package and deploy the doc_redaction agent (recommended)
|
| 170 |
+
|
| 171 |
+
Use [`package_runtime.py`](package_runtime.py) to sync `redaction_langgraph`, Pi helpers, session memory, and `main.py` into your AgentCore app folder β then deploy.
|
| 172 |
+
|
| 173 |
+
**Prerequisites:** `agentcore create` project at `agent-redact/RedactionAgent/` (or pass `--target`).
|
| 174 |
+
|
| 175 |
+
From the **doc_redaction repo root**:
|
| 176 |
+
|
| 177 |
+
```powershell
|
| 178 |
+
# Preview
|
| 179 |
+
python agent-redact/agentcore/package_runtime.py --dry-run
|
| 180 |
+
|
| 181 |
+
# Package into agent-redact/RedactionAgent/app/RedactionAgent/
|
| 182 |
+
python agent-redact/agentcore/package_runtime.py
|
| 183 |
+
|
| 184 |
+
# Package + deploy (set UV_LINK_MODE on Windows / OneDrive)
|
| 185 |
+
$env:UV_LINK_MODE = "copy"
|
| 186 |
+
python agent-redact/agentcore/package_runtime.py --deploy
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
Or manually after packaging:
|
| 190 |
+
|
| 191 |
+
```powershell
|
| 192 |
+
cd agent-redact\RedactionAgent
|
| 193 |
+
agentcore validate
|
| 194 |
+
agentcore deploy
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
**What the script copies**
|
| 198 |
+
|
| 199 |
+
| Source | Destination in `app/RedactionAgent/` |
|
| 200 |
+
|--------|--------------------------------------|
|
| 201 |
+
| `redaction_langgraph/` | `redaction_langgraph/` |
|
| 202 |
+
| `pi/bootstrap_pi_config.py`, `remote_redaction.py` | `pi/` |
|
| 203 |
+
| `agentcore/bundle_support/session_workspace.py` | `pi/session_workspace.py` (no Gradio dep) |
|
| 204 |
+
| `agentcore/invoke_agent.py`, `session_store.py` | app root |
|
| 205 |
+
| Generated `main.py` | replaces template `main.py` |
|
| 206 |
+
| Runtime deps | merged into `pyproject.toml` |
|
| 207 |
+
| `agentcore.env.example` | env vars to set on the **AWS runtime** |
|
| 208 |
+
|
| 209 |
+
**After deploy β runtime environment (AWS)**
|
| 210 |
+
|
| 211 |
+
Bedrock model settings (`AGENT_DEFAULT_PROVIDER`, `AWS_REGION`, β¦) belong in `agentcore.env` on the runtime bundle.
|
| 212 |
+
|
| 213 |
+
**`DOC_REDACTION_GRADIO_URL`:** the Gradio Pi UI sends this on **every invoke** in `runtime_config`, taken from your local `config/agent.env`. That overrides any URL baked into `agentcore.env` (for example an old HF Space default). You should see `Redaction backend for this turn: β¦` in the activity log with the same URL as the session info panel.
|
| 214 |
+
|
| 215 |
+
For AWS CDK + AgentCore, `DOC_REDACTION_GRADIO_URL` is the **main app HTTPS URL** (`CloudFrontDistributionURL` when `USE_CLOUDFRONT=True`, otherwise `ExpressServiceEndpoint` / `AgenticDocRedactionBackendUrl` stack output). Service Connect (`http://redaction:7860`) is only for in-container `pi` / `langgraph` orchestrators. For local Docker dev, set `DOC_REDACTION_GRADIO_URL=http://host.docker.internal:7861` in `agent.env`.
|
| 216 |
+
|
| 217 |
+
```bash
|
| 218 |
+
AGENT_DEFAULT_PROVIDER=amazon-bedrock
|
| 219 |
+
AGENT_DEFAULT_MODEL=anthropic.claude-sonnet-4-6
|
| 220 |
+
AWS_REGION=eu-west-2
|
| 221 |
+
AGENT_WORKSPACE_DIR=/tmp/agentcore-workspace
|
| 222 |
+
# Optional fallback if Gradio does not send runtime_config:
|
| 223 |
+
# DOC_REDACTION_GRADIO_URL=https://<ExpressServiceEndpoint> # CDK + AgentCore
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
**Session / follow-up chat:** [`session_store.py`](session_store.py) keeps conversation history per `session_hash` inside the running runtime process. Gradio passes the same `session_hash` for follow-ups. History is lost on cold start until [AgentCore Memory](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/) is configured.
|
| 227 |
+
|
| 228 |
+
**Workspace file sync (Gradio β AgentCore):** AgentCore runs on AWS with its own filesystem (`AGENT_WORKSPACE_DIR`). When you **Start redaction task** from the Gradio UI, the uploaded PDF is base64-encoded in the invoke payload (`workspace_files`) and written into the remote session workspace before the LangGraph agent runs. After the turn completes, artifacts under `redact/` are streamed back as `workspace_file` events and saved into the local Gradio session workspace so the **Outputs** panel can refresh. Default per-file limit: 8 MB (`AGENTCORE_MAX_UPLOAD_BYTES`). Re-deploy the runtime after upgrading `invoke_agent.py` / `workspace_sync.py`.
|
| 229 |
+
|
| 230 |
+
#### Alternative: Container build
|
| 231 |
+
|
| 232 |
+
For a full monorepo checkout in the image, switch the runtime in `agentcore/agentcore.json` to `"build": "Container"`, add a `Dockerfile` under `app/RedactionAgent/` that `COPY`s the repo and sets `CMD` to run the entrypoint, then `agentcore deploy`.
|
| 233 |
+
|
| 234 |
+
## Step 2 β Get the runtime URL
|
| 235 |
+
|
| 236 |
+
After a successful deploy:
|
| 237 |
+
|
| 238 |
+
```bash
|
| 239 |
+
agentcore status
|
| 240 |
+
# or non-interactive (PowerShell-friendly):
|
| 241 |
+
agentcore status --json
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
- **`agentcore status`** β runtime ARN, `invocationUrl`, deployment state, log hints (this is what you want for the HTTP endpoint)
|
| 245 |
+
- **`agentcore fetch access`** β only for **gateways** or agents using **CUSTOM_JWT** inbound auth (fetches bearer token guidance). It does **not** apply to the default AWS_IAM runtime agent; running bare `agentcore fetch` only prints subcommand help.
|
| 246 |
+
|
| 247 |
+
Example (your deployed runtime):
|
| 248 |
+
|
| 249 |
+
```text
|
| 250 |
+
AGENTCORE_RUNTIME_URL=https://bedrock-agentcore.eu-west-2.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aeu-west-2%3A404053085091%3Aruntime%2FRedactionAgent_RedactionAgent-ye5Jfw7gKj
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
Use the `invocationUrl` from `agentcore status --json` but **drop the trailing `/invocations`** β the Gradio client appends that path. Auth is **SigV4 (AWS IAM)**, not a static API key, unless you later configure CUSTOM_JWT on the runtime.
|
| 254 |
+
|
| 255 |
+
You can also find the runtime in the AWS console under **Amazon Bedrock β AgentCore** (runtime resources created by deploy).
|
| 256 |
+
|
| 257 |
+
### Build the URL from the runtime ARN
|
| 258 |
+
|
| 259 |
+
If you only have the ARN, the HTTP base is typically:
|
| 260 |
+
|
| 261 |
+
```text
|
| 262 |
+
https://bedrock-agentcore.<region>.amazonaws.com/runtimes/<URL-encoded-ARN>
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
URL-encode the ARN (`:` β `%3A`, `/` β `%2F`). See the [HTTP protocol contract](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-http-protocol-contract.html).
|
| 266 |
+
|
| 267 |
+
### Test invoke (CLI)
|
| 268 |
+
|
| 269 |
+
```bash
|
| 270 |
+
agentcore invoke --runtime RedactionAgent "Run Pass 1 redaction on the uploaded PDF"
|
| 271 |
+
agentcore invoke --runtime RedactionAgent "Hello" --stream
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
Programmatic invoke uses the AWS SDK `InvokeAgentRuntime` API with the runtime ARN ([AWS docs](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-get-started-cli.html)).
|
| 275 |
+
|
| 276 |
+
## Step 3 β Wire the Gradio agent UI
|
| 277 |
+
|
| 278 |
+
### Demonstration (CDK) install
|
| 279 |
+
|
| 280 |
+
The [CDK installer](../../cdk/cdk_install.py) demo profile (`--profile demo --enable-pi`) defaults to **AgentCore orchestration** for the Express agent Gradio UI. The Pi coding-agent CLI remains in the container image but is unused when `AGENT_ORCHESTRATOR=agentcore`.
|
| 281 |
+
|
| 282 |
+
#### Option C β CDK-native runtime from ECR (recommended for demo)
|
| 283 |
+
|
| 284 |
+
The installer can build an **ARM64** runtime image from this monorepo (CodeBuild β ECR) and create a **Bedrock AgentCore Runtime** via CDK (`aws_bedrockagentcore.CfnRuntime`) β no local `agentcore deploy` CLI required.
|
| 285 |
+
|
| 286 |
+
**Phase 1 β infra + runtime image**
|
| 287 |
+
|
| 288 |
+
```powershell
|
| 289 |
+
python cdk/cdk_install.py --profile demo --enable-agentic `
|
| 290 |
+
--enable-agentcore-cdk-deploy --yes
|
| 291 |
+
```
|
| 292 |
+
|
| 293 |
+
This sets `AGENTCORE_CDK_DEPLOY=True`, creates an ARM CodeBuild project and ECR repo (`agent-redact/agentcore/Dockerfile.runtime`), and runs `post_cdk_build_quickstart.py` to push the image.
|
| 294 |
+
|
| 295 |
+
**Phase 2 β create runtime + wire URL (no stack update)**
|
| 296 |
+
|
| 297 |
+
Phase 1 runs `post_cdk_build_quickstart.py`, which triggers the AgentCore CodeBuild and, if the image is ready before quickstart finishes, completes phase 2 automatically. If the image is **not** ready yet (or CodeBuild failed and you pushed the image manually), complete phase 2 once `agent-redact/agentcore/Dockerfile.runtime` has produced `β¦-agentcore-runtime:latest` in ECR:
|
| 298 |
+
|
| 299 |
+
```powershell
|
| 300 |
+
python cdk/cdk_install.py --complete-agentcore
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
`--complete-agentcore` uses the existing `cdk/config/cdk_config.env`, skips the wizard, and **does not run `cdk deploy`**. It calls the `bedrock-agentcore-control` API directly (boto3) to create the runtime from the ECR image + the phase-1 execution role (`AgentCoreRuntimeExecutionRole`, mirroring the CDK `CfnRuntime` parameters), then derives `AGENTCORE_RUNTIME_URL`, patches `config/agent.env` / `cdk/config/cdk_config.env`, re-uploads to S3, and recycles the agent Express service. It is idempotent β reuses an existing runtime of the same name.
|
| 304 |
+
|
| 305 |
+
> **Why not a second `cdk deploy`?** `cdk_install.py` performs **initial deploys only** and aborts rather than update an existing stack. This is deliberate: `app.py` re-runs the precheck on every `cdk` invocation, and a second deploy would find the stack's now-existing roles/buckets/pool, flip them from *managed* to *imported*, and CloudFormation would then **delete** them (removal policy `DESTROY` on the demo profile). So phase 2 creates the runtime out-of-band via the API instead β no stack mutation, no footgun. If you later tear down the stack, delete the AgentCore runtime separately (it is not tracked by CloudFormation).
|
| 306 |
+
|
| 307 |
+
Config keys (also in `cdk/config/cdk_config.env`):
|
| 308 |
+
|
| 309 |
+
| Variable | Role |
|
| 310 |
+
|----------|------|
|
| 311 |
+
| `AGENTCORE_CDK_DEPLOY` | Phase 1: CodeBuild + ECR + execution IAM role |
|
| 312 |
+
| `ENABLE_AGENTCORE_CDK_RUNTIME` | Phase 2: create `AWS::BedrockAgentCore::Runtime` |
|
| 313 |
+
| `ECR_AGENTCORE_REPO_NAME` | ECR repository for the runtime image |
|
| 314 |
+
| `CODEBUILD_AGENTCORE_PROJECT_NAME` | ARM64 CodeBuild project |
|
| 315 |
+
| `AGENTCORE_RUNTIME_NAME` | Runtime resource name (default `{CDK_PREFIX}RedactionAgent`) |
|
| 316 |
+
|
| 317 |
+
The runtime image is built from the **doc_redaction monorepo** (`GITHUB_REPO_*`); it copies `agent-redact/`, `redaction_langgraph/`, and `pi/` helpers β no separate GitHub repo required.
|
| 318 |
+
|
| 319 |
+
#### Option A/B β manual CLI deploy (alternative)
|
| 320 |
+
|
| 321 |
+
Deploy is **two-phase** when using the AgentCore CLI β the runtime URL does not exist until after `agentcore deploy`:
|
| 322 |
+
|
| 323 |
+
**Phase 1 β AgentCore runtime (before or after CDK; URL required before the agent UI works)**
|
| 324 |
+
|
| 325 |
+
```powershell
|
| 326 |
+
# From doc_redaction repo root (after agentcore create β see Option A/B above)
|
| 327 |
+
python agent-redact/agentcore/package_runtime.py `
|
| 328 |
+
--target C:\path\to\RedactionAgent\app\RedactionAgent
|
| 329 |
+
|
| 330 |
+
cd C:\path\to\RedactionAgent
|
| 331 |
+
$env:UV_LINK_MODE = "copy"
|
| 332 |
+
agentcore deploy
|
| 333 |
+
agentcore status # copy invocationUrl (base only, no /invocations)
|
| 334 |
+
```
|
| 335 |
+
|
| 336 |
+
Set runtime env on AWS so tools reach doc_redaction. For CDK + AgentCore use the **main Express HTTPS URL** (`ExpressServiceEndpoint`); `agentcore.env` is only a fallback β Gradio overrides via `runtime_config` each invoke.
|
| 337 |
+
|
| 338 |
+
**Phase 2 β CDK demo stack**
|
| 339 |
+
|
| 340 |
+
```powershell
|
| 341 |
+
# With runtime URL (recommended)
|
| 342 |
+
python cdk/cdk_install.py --profile demo --enable-pi `
|
| 343 |
+
--agentcore-runtime-url "https://bedrock-agentcore....amazonaws.com/runtimes/..." --yes
|
| 344 |
+
|
| 345 |
+
# Or config-only first; add URL after runtime deploy
|
| 346 |
+
python cdk/cdk_install.py --profile demo --enable-pi --yes --config-only
|
| 347 |
+
# then re-run with --agentcore-runtime-url or edit config/agent.env
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
The installer adds `policies/pi_agentcore_invoke_policy.json` to the ECS task role so the Gradio container can call `InvokeAgentRuntime` (SigV4, no `AGENTCORE_API_KEY` required on ECS).
|
| 351 |
+
|
| 352 |
+
**Order B (infra first):** deploy CDK with `--agent-orchestrator pi` or defer the URL in interactive mode, deploy the runtime, then `cdk_install.py --config-only --agentcore-runtime-url <URL>` and restart the Pi Express service.
|
| 353 |
+
|
| 354 |
+
**Fallback orchestrators:** `--agent-orchestrator pi` or `langgraph` runs orchestration inside the Express container (no separate AgentCore runtime).
|
| 355 |
+
|
| 356 |
+
---
|
| 357 |
+
|
| 358 |
+
Set in [`config/agent.env`](../../config/agent.env.example) (or via the CDK installer):
|
| 359 |
+
|
| 360 |
+
```bash
|
| 361 |
+
AGENT_ORCHESTRATOR=agentcore
|
| 362 |
+
AGENTCORE_RUNTIME_URL=https://bedrock-agentcore.eu-west-2.amazonaws.com/runtimes/...
|
| 363 |
+
# Optional if the runtime uses bearer/OAuth inbound auth:
|
| 364 |
+
AGENTCORE_API_KEY=your-bearer-token
|
| 365 |
+
```
|
| 366 |
+
|
| 367 |
+
**Harness orchestrator** (console-created Harness, Pi-like skills/shell β not the LangGraph bundle):
|
| 368 |
+
|
| 369 |
+
```bash
|
| 370 |
+
AGENT_ORCHESTRATOR=agentcore-harness
|
| 371 |
+
AGENTCORE_HARNESS_ARN=arn:aws:bedrock-agentcore:eu-west-2:...:harness/YourHarness-xyz
|
| 372 |
+
# Optional endpoint name (default DEFAULT):
|
| 373 |
+
# AGENTCORE_HARNESS_ENDPOINT=DEFAULT
|
| 374 |
+
# S3 file bridge for Start redaction task (upload PDF + presigned URL in prompt):
|
| 375 |
+
# AGENTCORE_HARNESS_S3_INPUT_PREFIX=s3://your-bucket/harness-inputs/
|
| 376 |
+
# AGENTCORE_HARNESS_S3_MOUNT_PATH=/tmp/workspace
|
| 377 |
+
RUN_AWS_FUNCTIONS=True
|
| 378 |
+
```
|
| 379 |
+
|
| 380 |
+
Client: [`agentcore_harness_runtime.py`](agentcore_harness_runtime.py). Requires a recent `boto3` with `invoke_harness`.
|
| 381 |
+
|
| 382 |
+
### CDK installer (non-interactive)
|
| 383 |
+
|
| 384 |
+
```bash
|
| 385 |
+
python cdk/cdk_install.py --profile demo --enable-pi \
|
| 386 |
+
--agent-orchestrator agentcore \
|
| 387 |
+
--agentcore-runtime-url "https://bedrock-agentcore.eu-west-2.amazonaws.com/runtimes/..." \
|
| 388 |
+
--yes
|
| 389 |
+
```
|
| 390 |
+
|
| 391 |
+
Interactive wizard: enable agent mode, then choose **Bedrock AgentCore** when prompted for the orchestration backend.
|
| 392 |
+
|
| 393 |
+
CDK writes `ENABLE_AGENTCORE_RUNTIME=True` and the URL into `config/cdk_config.env`; `agent.env` gets `AGENT_ORCHESTRATOR` and `AGENTCORE_RUNTIME_URL` for the Pi Express container.
|
| 394 |
+
|
| 395 |
+
### Typical deployment order
|
| 396 |
+
|
| 397 |
+
1. Deploy **doc_redaction** main app (CDK) and note **ExpressServiceEndpoint** (main Express HTTPS URL).
|
| 398 |
+
2. Deploy **AgentCore** runtime (`agentcore deploy`) and note the URL.
|
| 399 |
+
3. Set `AGENTCORE_RUNTIME_URL` in `agent.env` / installer and deploy or restart the **Pi Express** agent UI service.
|
| 400 |
+
|
| 401 |
+
You can deploy the Gradio UI first with `AGENT_ORCHESTRATOR=pi` or `langgraph` and switch to `agentcore` once the runtime URL is known.
|
| 402 |
+
|
| 403 |
+
## Authentication
|
| 404 |
+
|
| 405 |
+
| Caller | Typical auth |
|
| 406 |
+
|--------|----------------|
|
| 407 |
+
| **Same AWS account / SDK** | IAM β `bedrock-agentcore:InvokeAgentRuntime` on the runtime ARN |
|
| 408 |
+
| **Gradio container β HTTPS runtime** | Often **OAuth / bearer token** β set `AGENTCORE_API_KEY` for the Gradio β AgentCore HTTP client |
|
| 409 |
+
|
| 410 |
+
If invoke returns **401** or **403**, check AgentCore inbound auth configuration and that the Gradio task role or bearer token is allowed. See [Authenticate and authorize with Inbound Auth and Outbound Auth](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/) in the AgentCore docs.
|
| 411 |
+
|
| 412 |
+
## Alternatives (no AgentCore URL)
|
| 413 |
+
|
| 414 |
+
| `AGENT_ORCHESTRATOR` | When to use |
|
| 415 |
+
|----------------------|-------------|
|
| 416 |
+
| `pi` (default) | Current Pi agent; HF Space; bash + skills |
|
| 417 |
+
| `langgraph` | LangGraph inside the agent container (Docker / ECS); no managed runtime |
|
| 418 |
+
| `agentcore` | Managed AgentCore Runtime on AWS |
|
| 419 |
+
|
| 420 |
+
## Troubleshooting
|
| 421 |
+
|
| 422 |
+
| Issue | What to check |
|
| 423 |
+
|-------|----------------|
|
| 424 |
+
| Runtime init timeout / `RuntimeClientError: initialization time exceeded` | Container failed to import `main.py` within 30s. Check CloudWatch `/aws/bedrock-agentcore/runtimes/RedactionAgent_RedactionAgent-ye5Jfw7gKj/` **runtime-logs**. Common cause: packaged bootstrap calling Pi-only modules (`pi_workspace_skills`). Re-run `package_runtime.py` and redeploy. |
|
| 425 |
+
| 403 on `/invocations` | Runtime uses **AWS IAM**; Gradio must call via SigV4 (`boto3` `invoke_agent_runtime`) or set `AGENTCORE_API_KEY` for CUSTOM_JWT. Ensure `AGENT_AWS_PROFILE` / `~/.aws` in the pi-agent container and `bedrock-agentcore:InvokeAgentRuntime` on the runtime ARN. |
|
| 426 |
+
| Agent cannot reach doc_redaction | `DOC_REDACTION_GRADIO_URL` must be the **main app HTTPS URL** for AgentCore (not Service Connect). Check `CloudFrontDistributionURL` (when CloudFront is enabled) or `AgenticDocRedactionBackendUrl` stack output and activity log `Redaction backend for this turn: β¦` |
|
| 427 |
+
| CDK deploy fails | `cdk bootstrap`; `agentcore deploy -v` for verbose AgentCore errors |
|
| 428 |
+
| `Failed to parse: \`-\`` during **Synthesize CloudFormation** | Windows + path with spaces (e.g. `OneDrive - Lambeth Council`). AgentCore CDK runs `uv` with `shell: true` and unquoted paths; the `-` in the folder name is passed to `uv` as a bogus package. See below. |
|
| 429 |
+
| `hardlink` / `os error 396` during synth | Project on OneDrive; set `UV_LINK_MODE=copy` before deploy |
|
| 430 |
+
| Region errors | AgentCore availability per region; align `aws-targets.json` and Bedrock model region |
|
| 431 |
+
|
| 432 |
+
### Windows: `Failed to parse: \`-\`` (spaces in project path)
|
| 433 |
+
|
| 434 |
+
**Cause:** `agentcore deploy` packages Python deps with `uv` during CDK synth. On Windows the CDK subprocess uses a shell without quoting, so a path like:
|
| 435 |
+
|
| 436 |
+
`C:\Users\Sean\OneDrive - Lambeth Council\...\RedactionAgent`
|
| 437 |
+
|
| 438 |
+
is split at spaces and `uv` receives a lone `-` argument (from `OneDrive - Lambeth`).
|
| 439 |
+
|
| 440 |
+
**Fix (pick one):**
|
| 441 |
+
|
| 442 |
+
1. **Deploy from a short path without spaces** (recommended):
|
| 443 |
+
|
| 444 |
+
```powershell
|
| 445 |
+
# Copy (not junction) to a local non-synced folder if OneDrive hardlinks also fail
|
| 446 |
+
xcopy /E /I "...\agent-redact\RedactionAgent" C:\dev\RedactionAgent
|
| 447 |
+
cd C:\dev\RedactionAgent
|
| 448 |
+
$env:UV_LINK_MODE = "copy" # needed if cache/staging still hits OneDrive
|
| 449 |
+
agentcore deploy
|
| 450 |
+
```
|
| 451 |
+
|
| 452 |
+
2. **Junction** (quick test; staging may still land on OneDrive via the target):
|
| 453 |
+
|
| 454 |
+
```powershell
|
| 455 |
+
mklink /J C:\dev\RedactionAgent "...\agent-redact\RedactionAgent"
|
| 456 |
+
cd C:\dev\RedactionAgent
|
| 457 |
+
$env:UV_LINK_MODE = "copy"
|
| 458 |
+
agentcore deploy
|
| 459 |
+
```
|
| 460 |
+
|
| 461 |
+
Verified: `node dist/bin/cdk.js synth` succeeds from `C:\dev\RedactionAgent\agentcore\cdk` with `UV_LINK_MODE=copy`; it fails with the `-` parse error from the OneDrive path directly.
|
| 462 |
+
|
| 463 |
+
Post-deploy reminder from [`cdk/post_cdk_build_quickstart.py`](../../cdk/post_cdk_build_quickstart.py): when `ENABLE_AGENTCORE_RUNTIME=True`, run `agentcore deploy` for this entrypoint and ensure `AGENTCORE_RUNTIME_URL` is set before scaling the Pi agent service.
|
| 464 |
+
|
| 465 |
+
## References
|
| 466 |
+
|
| 467 |
+
- [Get started with the AgentCore CLI](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-get-started-cli.html)
|
| 468 |
+
- [HTTP protocol contract (`/invocations`)](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-http-protocol-contract.html)
|
| 469 |
+
- [bedrock-agentcore-sdk-python](https://github.com/aws/bedrock-agentcore-sdk-python)
|
| 470 |
+
- Repo: [`agent_runtime.py`](../shared/agent_runtime.py), [`agent-redact/pi/agent/README.md`](../pi/agent/README.md)
|
agent-redact/agentcore/agentcore_boto.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared boto3 helpers for Bedrock AgentCore runtime and harness clients."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from agent_runtime import AgentRuntimeError
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def bedrock_agentcore_client(region: str):
|
| 9 |
+
import boto3
|
| 10 |
+
from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError
|
| 11 |
+
from pi_agent_config import configure_aws_credentials
|
| 12 |
+
|
| 13 |
+
configure_aws_credentials()
|
| 14 |
+
session = boto3.Session(region_name=region)
|
| 15 |
+
try:
|
| 16 |
+
session.client("sts").get_caller_identity()
|
| 17 |
+
except (ClientError, BotoCoreError, NoCredentialsError) as exc:
|
| 18 |
+
raise AgentRuntimeError(
|
| 19 |
+
"AWS credentials are required to invoke AgentCore. "
|
| 20 |
+
"Set AWS_PROFILE / AGENT_AWS_PROFILE, mount ~/.aws into the pi-agent container, "
|
| 21 |
+
"or paste session keys under **Agent backend** β **Apply backend**. "
|
| 22 |
+
"For HTTP runtime auth with CUSTOM_JWT, set AGENTCORE_API_KEY instead."
|
| 23 |
+
) from exc
|
| 24 |
+
return session.client("bedrock-agentcore", region_name=region)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def region_from_agentcore_arn(arn: str, *, resource_label: str) -> str:
|
| 28 |
+
"""Return AWS region from a bedrock-agentcore ARN."""
|
| 29 |
+
normalized = (arn or "").strip()
|
| 30 |
+
if not normalized.startswith("arn:"):
|
| 31 |
+
raise AgentRuntimeError(
|
| 32 |
+
f"Expected an AgentCore {resource_label} ARN, got: {arn!r}"
|
| 33 |
+
)
|
| 34 |
+
parts = normalized.split(":")
|
| 35 |
+
if len(parts) < 6 or parts[2] != "bedrock-agentcore":
|
| 36 |
+
raise AgentRuntimeError(f"Invalid AgentCore {resource_label} ARN: {arn!r}")
|
| 37 |
+
region = parts[3].strip()
|
| 38 |
+
if not region:
|
| 39 |
+
raise AgentRuntimeError(
|
| 40 |
+
f"Could not parse region from {resource_label} ARN: {arn!r}"
|
| 41 |
+
)
|
| 42 |
+
if f":{resource_label}/" not in normalized:
|
| 43 |
+
raise AgentRuntimeError(
|
| 44 |
+
f"ARN must be a {resource_label} resource (arn:...:bedrock-agentcore:...:{resource_label}/...), "
|
| 45 |
+
f"got: {arn!r}"
|
| 46 |
+
)
|
| 47 |
+
return region
|
agent-redact/agentcore/agentcore_harness_runtime.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bedrock AgentCore Harness client :class:`AgentRuntime` for the Gradio UI."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from collections.abc import Iterator
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from agent_runtime import AgentRuntime, AgentRuntimeError, AgentStreamEvent
|
| 12 |
+
from agentcore_boto import bedrock_agentcore_client, region_from_agentcore_arn
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def agentcore_harness_arn() -> str:
|
| 16 |
+
raw = (os.environ.get("AGENTCORE_HARNESS_ARN") or "").strip()
|
| 17 |
+
if not raw:
|
| 18 |
+
raise AgentRuntimeError(
|
| 19 |
+
"AGENTCORE_HARNESS_ARN is not set. Create a Harness in the AgentCore console "
|
| 20 |
+
"or use AGENT_ORCHESTRATOR=pi|langgraph|agentcore for other backends."
|
| 21 |
+
)
|
| 22 |
+
return raw
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def agentcore_harness_endpoint() -> str:
|
| 26 |
+
return (
|
| 27 |
+
os.environ.get("AGENTCORE_HARNESS_ENDPOINT") or "DEFAULT"
|
| 28 |
+
).strip() or "DEFAULT"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def harness_runtime_session_id(session_hash: str | None) -> str:
|
| 32 |
+
"""Stable AgentCore Harness session id (must be at least 33 characters)."""
|
| 33 |
+
base = (session_hash or "default").strip() or "default"
|
| 34 |
+
digest = hashlib.sha256(base.encode("utf-8")).hexdigest()
|
| 35 |
+
return f"harness-{digest}"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def parse_agentcore_harness_arn(arn: str) -> tuple[str, str]:
|
| 39 |
+
"""Return ``(region, harness_arn)``."""
|
| 40 |
+
normalized = (arn or "").strip()
|
| 41 |
+
return region_from_agentcore_arn(normalized, resource_label="harness"), normalized
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def map_harness_stream_event(event: dict[str, Any]) -> Iterator[AgentStreamEvent]:
|
| 45 |
+
"""Map one ``InvokeHarness`` stream event to normalized Gradio events."""
|
| 46 |
+
if "runtimeClientError" in event:
|
| 47 |
+
err = event.get("runtimeClientError") or {}
|
| 48 |
+
message = str(err.get("message") or err.get("errorMessage") or "Harness error")
|
| 49 |
+
yield AgentStreamEvent(kind="error", text=message, is_error=True)
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
if "contentBlockDelta" in event:
|
| 53 |
+
delta = (event.get("contentBlockDelta") or {}).get("delta") or {}
|
| 54 |
+
text = delta.get("text")
|
| 55 |
+
if text:
|
| 56 |
+
yield AgentStreamEvent(kind="text_delta", text=str(text))
|
| 57 |
+
reasoning = (delta.get("reasoningContent") or {}).get("text")
|
| 58 |
+
if reasoning:
|
| 59 |
+
yield AgentStreamEvent(kind="thinking_delta", text=str(reasoning))
|
| 60 |
+
tool_input = delta.get("toolUse") or {}
|
| 61 |
+
if isinstance(tool_input, dict) and tool_input.get("input"):
|
| 62 |
+
yield AgentStreamEvent(
|
| 63 |
+
kind="status",
|
| 64 |
+
text=f"Tool input: {json.dumps(tool_input.get('input'), default=str)[:500]}",
|
| 65 |
+
)
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
if "contentBlockStart" in event:
|
| 69 |
+
start = (event.get("contentBlockStart") or {}).get("start") or {}
|
| 70 |
+
tool_use = start.get("toolUse") or {}
|
| 71 |
+
if isinstance(tool_use, dict) and tool_use.get("name"):
|
| 72 |
+
name = str(tool_use.get("name") or "tool")
|
| 73 |
+
tool_id = str(tool_use.get("toolUseId") or "")
|
| 74 |
+
args = (
|
| 75 |
+
tool_use.get("input") if isinstance(tool_use.get("input"), dict) else {}
|
| 76 |
+
)
|
| 77 |
+
yield AgentStreamEvent(
|
| 78 |
+
kind="tool_start",
|
| 79 |
+
tool_name=name,
|
| 80 |
+
tool_call_id=tool_id or None,
|
| 81 |
+
tool_args=args,
|
| 82 |
+
text=name,
|
| 83 |
+
)
|
| 84 |
+
return
|
| 85 |
+
|
| 86 |
+
if "contentBlockStop" in event:
|
| 87 |
+
stop = event.get("contentBlockStop") or {}
|
| 88 |
+
tool_result = stop.get("toolResult") or {}
|
| 89 |
+
if isinstance(tool_result, dict) and tool_result:
|
| 90 |
+
name = str(tool_result.get("toolName") or tool_result.get("name") or "tool")
|
| 91 |
+
content = (
|
| 92 |
+
tool_result.get("content") or tool_result.get("output") or tool_result
|
| 93 |
+
)
|
| 94 |
+
output = (
|
| 95 |
+
content
|
| 96 |
+
if isinstance(content, str)
|
| 97 |
+
else json.dumps(content, default=str)
|
| 98 |
+
)
|
| 99 |
+
yield AgentStreamEvent(
|
| 100 |
+
kind="tool_end",
|
| 101 |
+
tool_name=name,
|
| 102 |
+
tool_output=output,
|
| 103 |
+
is_error="error" in output.lower(),
|
| 104 |
+
)
|
| 105 |
+
return
|
| 106 |
+
|
| 107 |
+
if "messageStop" in event:
|
| 108 |
+
stop = event.get("messageStop") or {}
|
| 109 |
+
reason = str(stop.get("stopReason") or "")
|
| 110 |
+
if reason:
|
| 111 |
+
yield AgentStreamEvent(kind="status", text=f"Harness stopped: {reason}")
|
| 112 |
+
return
|
| 113 |
+
|
| 114 |
+
if "messageStart" in event:
|
| 115 |
+
yield AgentStreamEvent(kind="status", text="Harness message startedβ¦")
|
| 116 |
+
return
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class AgentCoreHarnessRuntime(AgentRuntime):
|
| 120 |
+
"""Proxy that streams events from a remote Bedrock AgentCore Harness."""
|
| 121 |
+
|
| 122 |
+
def __init__(self, *, session_hash: str | None = None) -> None:
|
| 123 |
+
self._session_hash = session_hash
|
| 124 |
+
self._running = False
|
| 125 |
+
self._prompt_stream_depth = 0
|
| 126 |
+
self._abort_requested = False
|
| 127 |
+
self._pending_ui_history: list[dict[str, Any]] = []
|
| 128 |
+
self._pending_prompt_prefix = ""
|
| 129 |
+
|
| 130 |
+
@property
|
| 131 |
+
def orchestrator(self) -> str:
|
| 132 |
+
return "agentcore-harness"
|
| 133 |
+
|
| 134 |
+
@property
|
| 135 |
+
def running(self) -> bool:
|
| 136 |
+
return self._running
|
| 137 |
+
|
| 138 |
+
@property
|
| 139 |
+
def prompt_stream_active(self) -> bool:
|
| 140 |
+
return self._prompt_stream_depth > 0
|
| 141 |
+
|
| 142 |
+
@property
|
| 143 |
+
def abort_requested(self) -> bool:
|
| 144 |
+
return self._abort_requested
|
| 145 |
+
|
| 146 |
+
def start(self) -> None:
|
| 147 |
+
agentcore_harness_arn()
|
| 148 |
+
self._running = True
|
| 149 |
+
|
| 150 |
+
def close(self) -> None:
|
| 151 |
+
self._running = False
|
| 152 |
+
|
| 153 |
+
def abort(self) -> None:
|
| 154 |
+
self._abort_requested = True
|
| 155 |
+
try:
|
| 156 |
+
region, _arn = parse_agentcore_harness_arn(agentcore_harness_arn())
|
| 157 |
+
client = bedrock_agentcore_client(region)
|
| 158 |
+
stop = getattr(client, "stop_runtime_session", None)
|
| 159 |
+
if callable(stop):
|
| 160 |
+
stop(runtimeSessionId=harness_runtime_session_id(self._session_hash))
|
| 161 |
+
except Exception:
|
| 162 |
+
pass
|
| 163 |
+
|
| 164 |
+
def new_session(self) -> None:
|
| 165 |
+
self._abort_requested = False
|
| 166 |
+
|
| 167 |
+
def set_model(self, provider: str, model_id: str) -> dict[str, Any]:
|
| 168 |
+
os.environ["AGENT_DEFAULT_PROVIDER"] = provider
|
| 169 |
+
os.environ["AGENT_DEFAULT_MODEL"] = model_id
|
| 170 |
+
return {"provider": provider, "model": model_id}
|
| 171 |
+
|
| 172 |
+
def get_state(self) -> dict[str, Any]:
|
| 173 |
+
return {
|
| 174 |
+
"isStreaming": self.prompt_stream_active,
|
| 175 |
+
"provider": "agentcore-harness",
|
| 176 |
+
"model": {
|
| 177 |
+
"provider": "agentcore-harness",
|
| 178 |
+
"id": agentcore_harness_arn(),
|
| 179 |
+
"endpoint": agentcore_harness_endpoint(),
|
| 180 |
+
},
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
def stage_ui_chat_notice(self, label: str, message: str) -> None:
|
| 184 |
+
text = message.strip()
|
| 185 |
+
if not text:
|
| 186 |
+
return
|
| 187 |
+
self._pending_ui_history.append(
|
| 188 |
+
{"role": "user", "content": f"_**{label}:**_ {text}"}
|
| 189 |
+
)
|
| 190 |
+
self._pending_ui_history.append({"role": "assistant", "content": ""})
|
| 191 |
+
|
| 192 |
+
def drain_pending_ui_history(self) -> list[dict[str, Any]]:
|
| 193 |
+
pending = self._pending_ui_history[:]
|
| 194 |
+
self._pending_ui_history.clear()
|
| 195 |
+
return pending
|
| 196 |
+
|
| 197 |
+
def stage_prompt_prefix(self, prefix: str) -> None:
|
| 198 |
+
text = (prefix or "").strip()
|
| 199 |
+
if text:
|
| 200 |
+
self._pending_prompt_prefix = f"{text.rstrip()}\n\n"
|
| 201 |
+
|
| 202 |
+
def prompt_events(self, message: str) -> Iterator[AgentStreamEvent]:
|
| 203 |
+
self._prompt_stream_depth += 1
|
| 204 |
+
self._abort_requested = False
|
| 205 |
+
try:
|
| 206 |
+
if not self._running:
|
| 207 |
+
self.start()
|
| 208 |
+
harness_arn = agentcore_harness_arn()
|
| 209 |
+
region, _parsed = parse_agentcore_harness_arn(harness_arn)
|
| 210 |
+
client = bedrock_agentcore_client(region)
|
| 211 |
+
invoke = getattr(client, "invoke_harness", None)
|
| 212 |
+
if not callable(invoke):
|
| 213 |
+
raise AgentRuntimeError(
|
| 214 |
+
"Your boto3 bedrock-agentcore client does not support invoke_harness. "
|
| 215 |
+
"Upgrade boto3/botocore in the pi-agent environment."
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
prompt = f"{self._pending_prompt_prefix}{message}"
|
| 219 |
+
self._pending_prompt_prefix = ""
|
| 220 |
+
session_id = harness_runtime_session_id(self._session_hash)
|
| 221 |
+
request: dict[str, Any] = {
|
| 222 |
+
"harnessArn": harness_arn,
|
| 223 |
+
"runtimeSessionId": session_id,
|
| 224 |
+
"messages": [
|
| 225 |
+
{
|
| 226 |
+
"role": "user",
|
| 227 |
+
"content": [{"text": prompt}],
|
| 228 |
+
}
|
| 229 |
+
],
|
| 230 |
+
}
|
| 231 |
+
endpoint = agentcore_harness_endpoint()
|
| 232 |
+
if endpoint and endpoint.upper() != "DEFAULT":
|
| 233 |
+
request["endpointName"] = endpoint
|
| 234 |
+
|
| 235 |
+
yield AgentStreamEvent(kind="status", text="AgentCore Harness startedβ¦")
|
| 236 |
+
try:
|
| 237 |
+
response = invoke(**request)
|
| 238 |
+
except Exception as exc:
|
| 239 |
+
from botocore.exceptions import ClientError
|
| 240 |
+
|
| 241 |
+
if isinstance(exc, ClientError):
|
| 242 |
+
code = exc.response.get("Error", {}).get("Code", "")
|
| 243 |
+
msg = exc.response.get("Error", {}).get("Message", str(exc))
|
| 244 |
+
hint = (
|
| 245 |
+
" Ensure your IAM identity has bedrock-agentcore:InvokeHarness on "
|
| 246 |
+
f"{harness_arn}."
|
| 247 |
+
)
|
| 248 |
+
raise AgentRuntimeError(f"{code}: {msg}.{hint}") from exc
|
| 249 |
+
raise AgentRuntimeError(str(exc)) from exc
|
| 250 |
+
|
| 251 |
+
stream = response.get("stream") or []
|
| 252 |
+
assistant_text: list[str] = []
|
| 253 |
+
for event in stream:
|
| 254 |
+
if self._abort_requested:
|
| 255 |
+
yield AgentStreamEvent(kind="done", text="Agent aborted.")
|
| 256 |
+
return
|
| 257 |
+
if not isinstance(event, dict):
|
| 258 |
+
continue
|
| 259 |
+
for mapped in map_harness_stream_event(event):
|
| 260 |
+
if mapped.kind == "text_delta" and mapped.text:
|
| 261 |
+
assistant_text.append(mapped.text)
|
| 262 |
+
yield mapped
|
| 263 |
+
|
| 264 |
+
if assistant_text:
|
| 265 |
+
yield AgentStreamEvent(
|
| 266 |
+
kind="text_snapshot", text="".join(assistant_text)
|
| 267 |
+
)
|
| 268 |
+
yield AgentStreamEvent(kind="done", text="Agent finished.")
|
| 269 |
+
finally:
|
| 270 |
+
self._prompt_stream_depth = max(0, self._prompt_stream_depth - 1)
|
agent-redact/agentcore/agentcore_runtime.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AgentCore HTTP/SSE client :class:`AgentRuntime` for the Gradio UI."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from collections.abc import Iterator
|
| 9 |
+
from typing import Any
|
| 10 |
+
from urllib.parse import unquote, urlparse
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
from agent_runtime import AgentRuntime, AgentRuntimeError, AgentStreamEvent
|
| 14 |
+
from agentcore_boto import bedrock_agentcore_client, region_from_agentcore_arn
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def agentcore_runtime_url() -> str:
|
| 18 |
+
raw = (os.environ.get("AGENTCORE_RUNTIME_URL") or "").strip().rstrip("/")
|
| 19 |
+
if raw.endswith("/invocations"):
|
| 20 |
+
raw = raw[: -len("/invocations")].rstrip("/")
|
| 21 |
+
if not raw:
|
| 22 |
+
raise AgentRuntimeError(
|
| 23 |
+
"AGENTCORE_RUNTIME_URL is not set. Deploy AgentCore runtime or use "
|
| 24 |
+
"AGENT_ORCHESTRATOR=pi|langgraph for local orchestration."
|
| 25 |
+
)
|
| 26 |
+
return raw
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def parse_agentcore_runtime_url(url: str) -> tuple[str, str]:
|
| 30 |
+
"""Return ``(region, agent_runtime_arn)`` from an AgentCore runtime base URL."""
|
| 31 |
+
normalized = (url or "").strip().rstrip("/")
|
| 32 |
+
if normalized.endswith("/invocations"):
|
| 33 |
+
normalized = normalized[: -len("/invocations")].rstrip("/")
|
| 34 |
+
parsed = urlparse(normalized)
|
| 35 |
+
host = (parsed.hostname or "").strip()
|
| 36 |
+
if not host.startswith("bedrock-agentcore."):
|
| 37 |
+
raise AgentRuntimeError(
|
| 38 |
+
f"AGENTCORE_RUNTIME_URL must be a bedrock-agentcore HTTPS URL, got: {url!r}"
|
| 39 |
+
)
|
| 40 |
+
region = host.removeprefix("bedrock-agentcore.").split(".", 1)[0].strip()
|
| 41 |
+
if not region:
|
| 42 |
+
raise AgentRuntimeError(
|
| 43 |
+
f"Could not parse AWS region from AgentCore URL: {url!r}"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
prefix = "/runtimes/"
|
| 47 |
+
path = parsed.path or ""
|
| 48 |
+
if not path.startswith(prefix):
|
| 49 |
+
raise AgentRuntimeError(
|
| 50 |
+
f"AGENTCORE_RUNTIME_URL must include /runtimes/<arn>, got path: {path!r}"
|
| 51 |
+
)
|
| 52 |
+
arn = unquote(path[len(prefix) :].strip("/"))
|
| 53 |
+
if not arn.startswith("arn:"):
|
| 54 |
+
raise AgentRuntimeError(
|
| 55 |
+
f"Could not parse runtime ARN from AgentCore URL: {url!r}"
|
| 56 |
+
)
|
| 57 |
+
return region_from_agentcore_arn(arn, resource_label="runtime"), arn
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _agentcore_api_key() -> str:
|
| 61 |
+
return (os.environ.get("AGENTCORE_API_KEY") or "").strip()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _bedrock_agentcore_client(region: str):
|
| 65 |
+
return bedrock_agentcore_client(region)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class AgentCoreAgentRuntime(AgentRuntime):
|
| 69 |
+
"""Proxy that streams events from a remote Bedrock AgentCore runtime."""
|
| 70 |
+
|
| 71 |
+
def __init__(self, *, session_hash: str | None = None) -> None:
|
| 72 |
+
self._session_hash = session_hash
|
| 73 |
+
self._running = False
|
| 74 |
+
self._prompt_stream_depth = 0
|
| 75 |
+
self._abort_requested = False
|
| 76 |
+
self._pending_ui_notices: list[dict[str, Any]] = []
|
| 77 |
+
self._pending_ui_history: list[dict[str, Any]] = []
|
| 78 |
+
self._pending_workspace_files: list[dict[str, str]] = []
|
| 79 |
+
self._sync_workspace_files = True
|
| 80 |
+
|
| 81 |
+
@property
|
| 82 |
+
def orchestrator(self) -> str:
|
| 83 |
+
return "agentcore"
|
| 84 |
+
|
| 85 |
+
@property
|
| 86 |
+
def running(self) -> bool:
|
| 87 |
+
return self._running
|
| 88 |
+
|
| 89 |
+
@property
|
| 90 |
+
def prompt_stream_active(self) -> bool:
|
| 91 |
+
return self._prompt_stream_depth > 0
|
| 92 |
+
|
| 93 |
+
@property
|
| 94 |
+
def abort_requested(self) -> bool:
|
| 95 |
+
return self._abort_requested
|
| 96 |
+
|
| 97 |
+
def start(self) -> None:
|
| 98 |
+
agentcore_runtime_url()
|
| 99 |
+
self._running = True
|
| 100 |
+
|
| 101 |
+
def close(self) -> None:
|
| 102 |
+
self._running = False
|
| 103 |
+
|
| 104 |
+
def abort(self) -> None:
|
| 105 |
+
self._abort_requested = True
|
| 106 |
+
|
| 107 |
+
def new_session(self) -> None:
|
| 108 |
+
self._abort_requested = False
|
| 109 |
+
|
| 110 |
+
def set_model(self, provider: str, model_id: str) -> dict[str, Any]:
|
| 111 |
+
os.environ["AGENT_DEFAULT_PROVIDER"] = provider
|
| 112 |
+
os.environ["AGENT_DEFAULT_MODEL"] = model_id
|
| 113 |
+
return {"provider": provider, "model": model_id}
|
| 114 |
+
|
| 115 |
+
def get_state(self) -> dict[str, Any]:
|
| 116 |
+
return {
|
| 117 |
+
"isStreaming": self.prompt_stream_active,
|
| 118 |
+
"provider": "agentcore",
|
| 119 |
+
"model": {"provider": "agentcore", "id": agentcore_runtime_url()},
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
def stage_ui_chat_notice(self, label: str, message: str) -> None:
|
| 123 |
+
text = message.strip()
|
| 124 |
+
if not text:
|
| 125 |
+
return
|
| 126 |
+
self._pending_ui_history.append(
|
| 127 |
+
{"role": "user", "content": f"_**{label}:**_ {text}"}
|
| 128 |
+
)
|
| 129 |
+
self._pending_ui_history.append({"role": "assistant", "content": ""})
|
| 130 |
+
|
| 131 |
+
def drain_pending_ui_history(self) -> list[dict[str, Any]]:
|
| 132 |
+
pending = self._pending_ui_history[:]
|
| 133 |
+
self._pending_ui_history.clear()
|
| 134 |
+
return pending
|
| 135 |
+
|
| 136 |
+
def stage_workspace_files(self, files: list[dict[str, str]]) -> None:
|
| 137 |
+
"""Queue files to upload into the remote AgentCore session workspace on next invoke."""
|
| 138 |
+
for item in files:
|
| 139 |
+
if not isinstance(item, dict):
|
| 140 |
+
continue
|
| 141 |
+
relative = str(item.get("relative_path") or item.get("name") or "").strip()
|
| 142 |
+
encoded = str(item.get("content_base64") or "").strip()
|
| 143 |
+
if relative and encoded:
|
| 144 |
+
self._pending_workspace_files.append(
|
| 145 |
+
{"relative_path": relative, "content_base64": encoded}
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
def set_sync_workspace_files(self, enabled: bool) -> None:
|
| 149 |
+
self._sync_workspace_files = bool(enabled)
|
| 150 |
+
|
| 151 |
+
def _write_local_workspace_file(
|
| 152 |
+
self, relative_path: str, content_base64: str
|
| 153 |
+
) -> None:
|
| 154 |
+
if not self._session_hash:
|
| 155 |
+
return
|
| 156 |
+
from session_workspace import session_workspace_dir
|
| 157 |
+
|
| 158 |
+
root = session_workspace_dir(self._session_hash).resolve()
|
| 159 |
+
dest = (root / relative_path).resolve()
|
| 160 |
+
try:
|
| 161 |
+
dest.relative_to(root)
|
| 162 |
+
except ValueError:
|
| 163 |
+
return
|
| 164 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 165 |
+
dest.write_bytes(base64.b64decode(content_base64, validate=True))
|
| 166 |
+
|
| 167 |
+
def prompt_events(self, message: str) -> Iterator[AgentStreamEvent]:
|
| 168 |
+
self._prompt_stream_depth += 1
|
| 169 |
+
self._abort_requested = False
|
| 170 |
+
try:
|
| 171 |
+
if not self._running:
|
| 172 |
+
self.start()
|
| 173 |
+
payload: dict[str, Any] = {
|
| 174 |
+
"prompt": message,
|
| 175 |
+
"session_hash": self._session_hash or "",
|
| 176 |
+
}
|
| 177 |
+
if self._pending_workspace_files:
|
| 178 |
+
payload["workspace_files"] = self._pending_workspace_files[:]
|
| 179 |
+
self._pending_workspace_files.clear()
|
| 180 |
+
if self._sync_workspace_files:
|
| 181 |
+
payload["sync_workspace_files"] = True
|
| 182 |
+
from agentcore_workspace_bridge import build_agentcore_invoke_runtime_config
|
| 183 |
+
|
| 184 |
+
runtime_config = build_agentcore_invoke_runtime_config()
|
| 185 |
+
if runtime_config:
|
| 186 |
+
payload["runtime_config"] = runtime_config
|
| 187 |
+
yield AgentStreamEvent(kind="status", text="AgentCore runtime startedβ¦")
|
| 188 |
+
if _agentcore_api_key():
|
| 189 |
+
yield from self._prompt_events_httpx(payload)
|
| 190 |
+
else:
|
| 191 |
+
yield from self._prompt_events_boto3(payload)
|
| 192 |
+
yield AgentStreamEvent(kind="done", text="Agent finished.")
|
| 193 |
+
except httpx.HTTPError as exc:
|
| 194 |
+
raise AgentRuntimeError(str(exc)) from exc
|
| 195 |
+
finally:
|
| 196 |
+
self._prompt_stream_depth = max(0, self._prompt_stream_depth - 1)
|
| 197 |
+
|
| 198 |
+
def _prompt_events_httpx(
|
| 199 |
+
self, payload: dict[str, Any]
|
| 200 |
+
) -> Iterator[AgentStreamEvent]:
|
| 201 |
+
url = f"{agentcore_runtime_url()}/invocations"
|
| 202 |
+
headers = {
|
| 203 |
+
"Content-Type": "application/json",
|
| 204 |
+
"Accept": "text/event-stream",
|
| 205 |
+
"Authorization": f"Bearer {_agentcore_api_key()}",
|
| 206 |
+
}
|
| 207 |
+
timeout = httpx.Timeout(connect=30.0, read=1800.0, write=30.0, pool=30.0)
|
| 208 |
+
with httpx.Client(timeout=timeout) as client:
|
| 209 |
+
with client.stream("POST", url, json=payload, headers=headers) as response:
|
| 210 |
+
response.raise_for_status()
|
| 211 |
+
yield from self._iter_sse_response(response.iter_lines())
|
| 212 |
+
|
| 213 |
+
def _prompt_events_boto3(
|
| 214 |
+
self, payload: dict[str, Any]
|
| 215 |
+
) -> Iterator[AgentStreamEvent]:
|
| 216 |
+
from botocore.exceptions import ClientError
|
| 217 |
+
|
| 218 |
+
region, runtime_arn = parse_agentcore_runtime_url(agentcore_runtime_url())
|
| 219 |
+
client = _bedrock_agentcore_client(region)
|
| 220 |
+
body = json.dumps(payload).encode("utf-8")
|
| 221 |
+
try:
|
| 222 |
+
response = client.invoke_agent_runtime(
|
| 223 |
+
agentRuntimeArn=runtime_arn,
|
| 224 |
+
payload=body,
|
| 225 |
+
contentType="application/json",
|
| 226 |
+
accept="text/event-stream",
|
| 227 |
+
)
|
| 228 |
+
except ClientError as exc:
|
| 229 |
+
code = exc.response.get("Error", {}).get("Code", "")
|
| 230 |
+
message = exc.response.get("Error", {}).get("Message", str(exc))
|
| 231 |
+
hint = (
|
| 232 |
+
" Ensure your IAM identity has bedrock-agentcore:InvokeAgentRuntime on "
|
| 233 |
+
f"{runtime_arn}."
|
| 234 |
+
)
|
| 235 |
+
if "initialization" in message.lower() or code == "RuntimeClientError":
|
| 236 |
+
hint += (
|
| 237 |
+
" The runtime container failed to start (import error or slow init). "
|
| 238 |
+
"Check CloudWatch log group "
|
| 239 |
+
f"/aws/bedrock-agentcore/runtimes/{runtime_arn.rsplit('/', 1)[-1]}/"
|
| 240 |
+
" then re-run package_runtime.py and agentcore deploy."
|
| 241 |
+
)
|
| 242 |
+
raise AgentRuntimeError(f"{code}: {message}.{hint}") from exc
|
| 243 |
+
|
| 244 |
+
status_code = int(response.get("statusCode") or 200)
|
| 245 |
+
if status_code >= 400:
|
| 246 |
+
raise AgentRuntimeError(
|
| 247 |
+
f"AgentCore invoke failed with HTTP {status_code} for runtime {runtime_arn}."
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
stream = response.get("response")
|
| 251 |
+
if stream is None:
|
| 252 |
+
return
|
| 253 |
+
if hasattr(stream, "iter_lines"):
|
| 254 |
+
yield from self._iter_sse_response(
|
| 255 |
+
(
|
| 256 |
+
line.decode("utf-8", errors="replace")
|
| 257 |
+
if isinstance(line, (bytes, bytearray))
|
| 258 |
+
else str(line)
|
| 259 |
+
)
|
| 260 |
+
for line in stream.iter_lines()
|
| 261 |
+
)
|
| 262 |
+
return
|
| 263 |
+
raw = stream.read() if hasattr(stream, "read") else stream
|
| 264 |
+
if isinstance(raw, str):
|
| 265 |
+
raw_bytes = raw.encode("utf-8")
|
| 266 |
+
else:
|
| 267 |
+
raw_bytes = bytes(raw or b"")
|
| 268 |
+
content_type = str(response.get("contentType") or "").lower()
|
| 269 |
+
if "event-stream" in content_type or raw_bytes.strip().startswith(b"data:"):
|
| 270 |
+
yield from self._iter_sse_lines(
|
| 271 |
+
line.decode("utf-8", errors="replace")
|
| 272 |
+
for line in raw_bytes.splitlines()
|
| 273 |
+
)
|
| 274 |
+
else:
|
| 275 |
+
yield from self._iter_json_response(raw_bytes)
|
| 276 |
+
|
| 277 |
+
def _iter_sse_response(self, lines: Iterator[str]) -> Iterator[AgentStreamEvent]:
|
| 278 |
+
for line in lines:
|
| 279 |
+
if self._abort_requested:
|
| 280 |
+
yield AgentStreamEvent(kind="done", text="Agent aborted.")
|
| 281 |
+
return
|
| 282 |
+
if not line or not line.startswith("data:"):
|
| 283 |
+
continue
|
| 284 |
+
data = line[5:].strip()
|
| 285 |
+
if not data or data == "[DONE]":
|
| 286 |
+
continue
|
| 287 |
+
try:
|
| 288 |
+
event = json.loads(data)
|
| 289 |
+
except json.JSONDecodeError:
|
| 290 |
+
yield AgentStreamEvent(kind="text_delta", text=data)
|
| 291 |
+
continue
|
| 292 |
+
yield from self._map_agentcore_event(event)
|
| 293 |
+
|
| 294 |
+
def _iter_sse_lines(self, lines: Iterator[str]) -> Iterator[AgentStreamEvent]:
|
| 295 |
+
yield from self._iter_sse_response(lines)
|
| 296 |
+
|
| 297 |
+
def _iter_json_response(self, raw: bytes) -> Iterator[AgentStreamEvent]:
|
| 298 |
+
if not raw.strip():
|
| 299 |
+
return
|
| 300 |
+
try:
|
| 301 |
+
payload = json.loads(raw.decode("utf-8"))
|
| 302 |
+
except json.JSONDecodeError:
|
| 303 |
+
yield AgentStreamEvent(
|
| 304 |
+
kind="text_snapshot", text=raw.decode("utf-8", errors="replace")
|
| 305 |
+
)
|
| 306 |
+
return
|
| 307 |
+
if isinstance(payload, dict):
|
| 308 |
+
if payload.get("type") == "error":
|
| 309 |
+
yield AgentStreamEvent(
|
| 310 |
+
kind="error",
|
| 311 |
+
text=str(payload.get("message") or "AgentCore error"),
|
| 312 |
+
is_error=True,
|
| 313 |
+
)
|
| 314 |
+
return
|
| 315 |
+
if "result" in payload:
|
| 316 |
+
yield AgentStreamEvent(
|
| 317 |
+
kind="text_snapshot", text=str(payload["result"])
|
| 318 |
+
)
|
| 319 |
+
return
|
| 320 |
+
yield AgentStreamEvent(
|
| 321 |
+
kind="text_snapshot",
|
| 322 |
+
text=json.dumps(payload, default=str),
|
| 323 |
+
)
|
| 324 |
+
else:
|
| 325 |
+
yield AgentStreamEvent(kind="text_snapshot", text=str(payload))
|
| 326 |
+
|
| 327 |
+
def _map_agentcore_event(self, event: dict[str, Any]) -> Iterator[AgentStreamEvent]:
|
| 328 |
+
event_type = str(event.get("type") or "")
|
| 329 |
+
if event_type == "agent_start":
|
| 330 |
+
yield AgentStreamEvent(kind="status", text="Agent startedβ¦")
|
| 331 |
+
elif event_type == "agent_end":
|
| 332 |
+
yield AgentStreamEvent(
|
| 333 |
+
kind="status", text=str(event.get("message") or "Agent finished.")
|
| 334 |
+
)
|
| 335 |
+
elif event_type == "status":
|
| 336 |
+
yield AgentStreamEvent(kind="status", text=str(event.get("message") or ""))
|
| 337 |
+
elif event_type == "error":
|
| 338 |
+
yield AgentStreamEvent(
|
| 339 |
+
kind="error",
|
| 340 |
+
text=str(event.get("message") or "AgentCore error"),
|
| 341 |
+
is_error=True,
|
| 342 |
+
)
|
| 343 |
+
elif event_type == "workspace_file":
|
| 344 |
+
relative = str(event.get("relative_path") or "").strip()
|
| 345 |
+
encoded = str(event.get("content_base64") or "").strip()
|
| 346 |
+
if relative and encoded:
|
| 347 |
+
try:
|
| 348 |
+
self._write_local_workspace_file(relative, encoded)
|
| 349 |
+
yield AgentStreamEvent(
|
| 350 |
+
kind="status",
|
| 351 |
+
text=f"Downloaded `{relative}` from AgentCore workspace.",
|
| 352 |
+
)
|
| 353 |
+
if relative.lower().endswith("_redacted.pdf"):
|
| 354 |
+
yield AgentStreamEvent(kind="workspace_sync")
|
| 355 |
+
except (OSError, ValueError) as exc:
|
| 356 |
+
yield AgentStreamEvent(
|
| 357 |
+
kind="status",
|
| 358 |
+
text=f"Could not save `{relative}` locally: {exc}",
|
| 359 |
+
)
|
| 360 |
+
elif event_type == "message_update":
|
| 361 |
+
role = str(event.get("role") or "")
|
| 362 |
+
if role == "tool":
|
| 363 |
+
tool_name = str(event.get("tool_name") or "tool")
|
| 364 |
+
content = event.get("content")
|
| 365 |
+
output = (
|
| 366 |
+
content
|
| 367 |
+
if isinstance(content, str)
|
| 368 |
+
else json.dumps(content, default=str)
|
| 369 |
+
)
|
| 370 |
+
is_error = "error" in output.lower() or "not found" in output.lower()
|
| 371 |
+
yield AgentStreamEvent(
|
| 372 |
+
kind="tool_end",
|
| 373 |
+
tool_name=tool_name,
|
| 374 |
+
tool_output=output,
|
| 375 |
+
is_error=is_error,
|
| 376 |
+
)
|
| 377 |
+
else:
|
| 378 |
+
content = event.get("content")
|
| 379 |
+
text = (
|
| 380 |
+
content
|
| 381 |
+
if isinstance(content, str)
|
| 382 |
+
else json.dumps(content, default=str)
|
| 383 |
+
)
|
| 384 |
+
tool_calls = event.get("tool_calls") or []
|
| 385 |
+
if isinstance(tool_calls, list):
|
| 386 |
+
for call in tool_calls:
|
| 387 |
+
if not isinstance(call, dict):
|
| 388 |
+
continue
|
| 389 |
+
name = str(call.get("name") or "tool")
|
| 390 |
+
args = (
|
| 391 |
+
call.get("args")
|
| 392 |
+
if isinstance(call.get("args"), dict)
|
| 393 |
+
else {}
|
| 394 |
+
)
|
| 395 |
+
yield AgentStreamEvent(
|
| 396 |
+
kind="tool_start",
|
| 397 |
+
tool_name=name,
|
| 398 |
+
tool_args=args,
|
| 399 |
+
text=name,
|
| 400 |
+
)
|
| 401 |
+
if text.strip():
|
| 402 |
+
yield AgentStreamEvent(kind="text_snapshot", text=text)
|
| 403 |
+
else:
|
| 404 |
+
yield AgentStreamEvent(
|
| 405 |
+
kind="status", text=json.dumps(event, default=str)[:500]
|
| 406 |
+
)
|
agent-redact/agentcore/agentcore_workspace_bridge.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bridge local Gradio session workspaces to AgentCore runtime invokes."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from session_workspace import session_workspace_dir
|
| 11 |
+
|
| 12 |
+
_SKIP_UPLOAD_PREFIXES = (
|
| 13 |
+
"preview/",
|
| 14 |
+
".pi/preview/",
|
| 15 |
+
"output_final_download/",
|
| 16 |
+
)
|
| 17 |
+
_DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
| 18 |
+
_DEFAULT_MAX_FILES = 80
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def max_upload_bytes() -> int:
|
| 22 |
+
raw = (os.environ.get("AGENTCORE_MAX_UPLOAD_BYTES") or "").strip()
|
| 23 |
+
if raw.isdigit():
|
| 24 |
+
return int(raw)
|
| 25 |
+
return _DEFAULT_MAX_BYTES
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def max_upload_files() -> int:
|
| 29 |
+
raw = (os.environ.get("AGENTCORE_MAX_UPLOAD_FILES") or "").strip()
|
| 30 |
+
if raw.isdigit():
|
| 31 |
+
return max(1, int(raw))
|
| 32 |
+
return _DEFAULT_MAX_FILES
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def discover_session_document_name(session_hash: str) -> str | None:
|
| 36 |
+
"""Return the newest PDF basename at the session workspace root."""
|
| 37 |
+
root = session_workspace_dir(session_hash)
|
| 38 |
+
if not root.is_dir():
|
| 39 |
+
return None
|
| 40 |
+
candidates: list[tuple[float, str]] = []
|
| 41 |
+
for path in root.glob("*.pdf"):
|
| 42 |
+
if not path.is_file():
|
| 43 |
+
continue
|
| 44 |
+
try:
|
| 45 |
+
candidates.append((path.stat().st_mtime, path.name))
|
| 46 |
+
except OSError:
|
| 47 |
+
continue
|
| 48 |
+
if not candidates:
|
| 49 |
+
return None
|
| 50 |
+
return max(candidates, key=lambda item: item[0])[1]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _should_upload_relative_path(relative: str) -> bool:
|
| 54 |
+
rel = relative.replace("\\", "/").lstrip("/")
|
| 55 |
+
if any(rel.startswith(prefix) for prefix in _SKIP_UPLOAD_PREFIXES):
|
| 56 |
+
return False
|
| 57 |
+
if rel.lower().endswith(".pdf") and "/" not in rel:
|
| 58 |
+
return True
|
| 59 |
+
return rel.startswith("redact/")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _upload_priority(relative: str) -> tuple[int, str]:
|
| 63 |
+
rel = relative.replace("\\", "/")
|
| 64 |
+
if rel.lower().endswith("_review_file.csv"):
|
| 65 |
+
return (0, rel)
|
| 66 |
+
if rel.lower().endswith(".pdf") and "/" not in rel:
|
| 67 |
+
return (1, rel)
|
| 68 |
+
if rel.startswith("redact/") and rel.lower().endswith("_redacted.pdf"):
|
| 69 |
+
return (2, rel)
|
| 70 |
+
if rel.startswith("redact/"):
|
| 71 |
+
return (3, rel)
|
| 72 |
+
return (9, rel)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def collect_session_files_for_agentcore_upload(
|
| 76 |
+
session_hash: str,
|
| 77 |
+
*,
|
| 78 |
+
document_name: str | None = None,
|
| 79 |
+
) -> list[dict[str, str]]:
|
| 80 |
+
"""
|
| 81 |
+
Collect local session files to seed the remote AgentCore workspace.
|
| 82 |
+
|
| 83 |
+
Includes the source PDF and everything under ``redact/`` (review CSVs, OCR
|
| 84 |
+
exports, etc.) so follow-up turns work after a runtime cold start.
|
| 85 |
+
"""
|
| 86 |
+
root = session_workspace_dir(session_hash).resolve()
|
| 87 |
+
if not root.is_dir():
|
| 88 |
+
return []
|
| 89 |
+
|
| 90 |
+
limit_bytes = max_upload_bytes()
|
| 91 |
+
limit_files = max_upload_files()
|
| 92 |
+
doc_name = (
|
| 93 |
+
document_name or discover_session_document_name(session_hash) or ""
|
| 94 |
+
).strip()
|
| 95 |
+
|
| 96 |
+
candidates: list[tuple[tuple[int, str], Path]] = []
|
| 97 |
+
for path in sorted(root.rglob("*")):
|
| 98 |
+
if not path.is_file():
|
| 99 |
+
continue
|
| 100 |
+
try:
|
| 101 |
+
relative = path.relative_to(root).as_posix()
|
| 102 |
+
except ValueError:
|
| 103 |
+
continue
|
| 104 |
+
if doc_name and relative == doc_name:
|
| 105 |
+
candidates.append(((1, relative), path))
|
| 106 |
+
continue
|
| 107 |
+
if not _should_upload_relative_path(relative):
|
| 108 |
+
continue
|
| 109 |
+
candidates.append((_upload_priority(relative), path))
|
| 110 |
+
|
| 111 |
+
if doc_name:
|
| 112 |
+
doc_path = (root / doc_name).resolve()
|
| 113 |
+
if doc_path.is_file() and all(path != doc_path for _, path in candidates):
|
| 114 |
+
candidates.append(((1, doc_name), doc_path))
|
| 115 |
+
|
| 116 |
+
candidates.sort(key=lambda item: item[0])
|
| 117 |
+
staged: list[dict[str, str]] = []
|
| 118 |
+
skipped_large: list[str] = []
|
| 119 |
+
|
| 120 |
+
for _, path in candidates:
|
| 121 |
+
if len(staged) >= limit_files:
|
| 122 |
+
break
|
| 123 |
+
try:
|
| 124 |
+
size = path.stat().st_size
|
| 125 |
+
except OSError:
|
| 126 |
+
continue
|
| 127 |
+
if size > limit_bytes:
|
| 128 |
+
skipped_large.append(path.relative_to(root).as_posix())
|
| 129 |
+
continue
|
| 130 |
+
try:
|
| 131 |
+
payload = path.read_bytes()
|
| 132 |
+
except OSError:
|
| 133 |
+
continue
|
| 134 |
+
staged.append(
|
| 135 |
+
{
|
| 136 |
+
"relative_path": path.relative_to(root).as_posix(),
|
| 137 |
+
"content_base64": base64.b64encode(payload).decode("ascii"),
|
| 138 |
+
}
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
if skipped_large and staged:
|
| 142 |
+
staged.append(
|
| 143 |
+
{
|
| 144 |
+
"relative_path": ".agentcore_upload_skipped.txt",
|
| 145 |
+
"content_base64": base64.b64encode(
|
| 146 |
+
(
|
| 147 |
+
"Some local files were not uploaded (over "
|
| 148 |
+
f"{limit_bytes:,} bytes):\n" + "\n".join(skipped_large[:20])
|
| 149 |
+
).encode("utf-8")
|
| 150 |
+
).decode("ascii"),
|
| 151 |
+
}
|
| 152 |
+
)
|
| 153 |
+
return staged
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _find_review_csv_paths(session_hash: str) -> list[str]:
|
| 157 |
+
root = session_workspace_dir(session_hash)
|
| 158 |
+
if not root.is_dir():
|
| 159 |
+
return []
|
| 160 |
+
found: list[str] = []
|
| 161 |
+
for path in sorted(root.rglob("*_review_file.csv")):
|
| 162 |
+
if not path.is_file():
|
| 163 |
+
continue
|
| 164 |
+
try:
|
| 165 |
+
found.append(path.relative_to(root).as_posix())
|
| 166 |
+
except ValueError:
|
| 167 |
+
continue
|
| 168 |
+
return found[:5]
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def build_agentcore_followup_context(
|
| 172 |
+
session_hash: str,
|
| 173 |
+
history: list[dict[str, Any]] | None = None,
|
| 174 |
+
) -> str:
|
| 175 |
+
"""Prompt prefix so follow-ups continue Pass 1 instead of restarting upload flow."""
|
| 176 |
+
doc = discover_session_document_name(session_hash)
|
| 177 |
+
review_csvs = _find_review_csv_paths(session_hash)
|
| 178 |
+
lines = [
|
| 179 |
+
"**AgentCore follow-up (mandatory):** Pass 1 redaction already ran in this "
|
| 180 |
+
"session. The local UI synced workspace artifacts into your session folder "
|
| 181 |
+
"before this message β call `list_workspace_files` first.",
|
| 182 |
+
"Do **not** ask the user to re-upload the PDF unless `list_workspace_files` "
|
| 183 |
+
"is empty after sync.",
|
| 184 |
+
"Prefer editing the existing `*_review_file.csv` and running `review_apply` "
|
| 185 |
+
"again (or `verify_coverage` first) rather than a full new `doc_redact`.",
|
| 186 |
+
]
|
| 187 |
+
if doc:
|
| 188 |
+
lines.append(f"**Source document:** `{doc}`")
|
| 189 |
+
lines.append(f"**Redaction tree:** `redact/{doc}/`")
|
| 190 |
+
if review_csvs:
|
| 191 |
+
lines.append(
|
| 192 |
+
"**Review CSV(s):** " + ", ".join(f"`{path}`" for path in review_csvs)
|
| 193 |
+
)
|
| 194 |
+
if history:
|
| 195 |
+
excerpt = _format_history_excerpt(history)
|
| 196 |
+
if excerpt:
|
| 197 |
+
lines.append("**Prior chat (UI, for context):**\n" + excerpt)
|
| 198 |
+
return "\n".join(lines) + "\n\n"
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _format_history_excerpt(
|
| 202 |
+
history: list[dict[str, Any]],
|
| 203 |
+
*,
|
| 204 |
+
max_messages: int = 10,
|
| 205 |
+
max_chars: int = 6000,
|
| 206 |
+
) -> str:
|
| 207 |
+
chunks: list[str] = []
|
| 208 |
+
total = 0
|
| 209 |
+
for message in history[-max_messages:]:
|
| 210 |
+
role = str(message.get("role") or "user").strip()
|
| 211 |
+
content = str(message.get("content") or "").strip()
|
| 212 |
+
if not content:
|
| 213 |
+
continue
|
| 214 |
+
line = f"- **{role}:** {content[:1500]}"
|
| 215 |
+
if total + len(line) > max_chars:
|
| 216 |
+
break
|
| 217 |
+
chunks.append(line)
|
| 218 |
+
total += len(line)
|
| 219 |
+
return "\n".join(chunks)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
_CLOUDFRONT_OVERLAY_KEYS = (
|
| 223 |
+
"DOC_REDACTION_GRADIO_URL",
|
| 224 |
+
"DOC_REDACTION_AUTH_TOKEN",
|
| 225 |
+
"DOC_REDACTION_AUTH_COOKIE_NAME",
|
| 226 |
+
# AgentCore runtime URL + model are created/finalised in deploy phase 2 and
|
| 227 |
+
# re-uploaded to agent.env in S3; the container's task env is fixed at synth,
|
| 228 |
+
# so pick them up here on (re)start rather than requiring a stack update.
|
| 229 |
+
"AGENTCORE_RUNTIME_URL",
|
| 230 |
+
"AGENT_DEFAULT_MODEL",
|
| 231 |
+
"AGENT_DEFAULT_PROVIDER",
|
| 232 |
+
)
|
| 233 |
+
_cloudfront_overlay_applied = False
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def apply_agentcore_cloudfront_config_overlay() -> dict[str, str]:
|
| 237 |
+
"""Overlay post-deploy AgentCore settings from S3 into ``os.environ`` (AgentCore only).
|
| 238 |
+
|
| 239 |
+
The Pi Express task definition is fixed at synth time and cannot carry values
|
| 240 |
+
that only exist after deploy phase 2 β the (later-created) CloudFront
|
| 241 |
+
domain/magic-link token and the AgentCore runtime URL/model β and injecting
|
| 242 |
+
them into the task env would create a dependency cycle. So when running as the
|
| 243 |
+
AgentCore orchestrator, we fetch the post-deploy ``agent.env`` from S3 and
|
| 244 |
+
override those keys (see ``_CLOUDFRONT_OVERLAY_KEYS``): the CloudFront backend
|
| 245 |
+
URL + token cookie for ``build_agentcore_invoke_runtime_config``, plus
|
| 246 |
+
``AGENTCORE_RUNTIME_URL`` / ``AGENT_DEFAULT_MODEL`` so the running container
|
| 247 |
+
reaches the runtime and shows the correct model without a stack update.
|
| 248 |
+
|
| 249 |
+
Controlled by ``DOC_REDACTION_CONFIG_S3_BUCKET`` / ``DOC_REDACTION_CONFIG_S3_KEY``
|
| 250 |
+
(set by CDK on every Express deploy). The overlay runs whenever that S3 config
|
| 251 |
+
source is present and will **promote** the container to ``AGENT_ORCHESTRATOR=agentcore``
|
| 252 |
+
if the S3 ``agent.env`` says so β this lets deploy phase 2 (which re-uploads
|
| 253 |
+
``agent.env`` and recycles the service) flip an already-running container to
|
| 254 |
+
AgentCore without a stack update. It never demotes an already-agentcore
|
| 255 |
+
container and is a no-op when the S3 config does not select AgentCore.
|
| 256 |
+
Best-effort and idempotent; returns the keys it overrode.
|
| 257 |
+
"""
|
| 258 |
+
global _cloudfront_overlay_applied
|
| 259 |
+
applied: dict[str, str] = {}
|
| 260 |
+
if _cloudfront_overlay_applied:
|
| 261 |
+
return applied
|
| 262 |
+
already_agentcore = (
|
| 263 |
+
os.environ.get("AGENT_ORCHESTRATOR") or ""
|
| 264 |
+
).strip().lower() == "agentcore"
|
| 265 |
+
bucket = (os.environ.get("DOC_REDACTION_CONFIG_S3_BUCKET") or "").strip()
|
| 266 |
+
if not bucket:
|
| 267 |
+
# No post-deploy S3 config source; rely solely on the task env.
|
| 268 |
+
_cloudfront_overlay_applied = True
|
| 269 |
+
return applied
|
| 270 |
+
key = (os.environ.get("DOC_REDACTION_CONFIG_S3_KEY") or "agent.env").strip()
|
| 271 |
+
|
| 272 |
+
try:
|
| 273 |
+
import boto3
|
| 274 |
+
|
| 275 |
+
region = (
|
| 276 |
+
os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or None
|
| 277 |
+
)
|
| 278 |
+
body = (
|
| 279 |
+
boto3.client("s3", region_name=region)
|
| 280 |
+
.get_object(Bucket=bucket, Key=key)["Body"]
|
| 281 |
+
.read()
|
| 282 |
+
.decode("utf-8", "replace")
|
| 283 |
+
)
|
| 284 |
+
except Exception as exc: # noqa: BLE001 - best effort, keep the UI booting
|
| 285 |
+
print(f"AgentCore config overlay skipped ({bucket}/{key}): {exc}")
|
| 286 |
+
_cloudfront_overlay_applied = True
|
| 287 |
+
return applied
|
| 288 |
+
|
| 289 |
+
parsed: dict[str, str] = {}
|
| 290 |
+
for raw_line in body.splitlines():
|
| 291 |
+
line = raw_line.strip()
|
| 292 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 293 |
+
continue
|
| 294 |
+
name, _, value = line.partition("=")
|
| 295 |
+
parsed[name.strip()] = value.strip().strip('"').strip("'")
|
| 296 |
+
|
| 297 |
+
# Only act when this deployment actually selects AgentCore (task env already
|
| 298 |
+
# says so, or the S3 config does). This keeps the overlay a no-op for pi /
|
| 299 |
+
# langgraph deployments that share the same config bucket.
|
| 300 |
+
s3_agentcore = (
|
| 301 |
+
parsed.get("AGENT_ORCHESTRATOR") or ""
|
| 302 |
+
).strip().lower() == "agentcore"
|
| 303 |
+
if not (already_agentcore or s3_agentcore):
|
| 304 |
+
_cloudfront_overlay_applied = True
|
| 305 |
+
return applied
|
| 306 |
+
|
| 307 |
+
if s3_agentcore and not already_agentcore:
|
| 308 |
+
os.environ["AGENT_ORCHESTRATOR"] = "agentcore"
|
| 309 |
+
applied["AGENT_ORCHESTRATOR"] = "agentcore"
|
| 310 |
+
|
| 311 |
+
for name in _CLOUDFRONT_OVERLAY_KEYS:
|
| 312 |
+
value = parsed.get(name)
|
| 313 |
+
if value:
|
| 314 |
+
os.environ[name] = value
|
| 315 |
+
applied[name] = value
|
| 316 |
+
|
| 317 |
+
_cloudfront_overlay_applied = True
|
| 318 |
+
if applied:
|
| 319 |
+
print(
|
| 320 |
+
"Applied AgentCore config overlay from S3 for: "
|
| 321 |
+
+ ", ".join(sorted(applied))
|
| 322 |
+
)
|
| 323 |
+
return applied
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def build_agentcore_invoke_runtime_config() -> dict[str, str]:
|
| 327 |
+
"""
|
| 328 |
+
Backend settings from the local Gradio process for each AgentCore invoke.
|
| 329 |
+
|
| 330 |
+
Overrides ``agentcore.env`` on the AWS runtime so the remote agent uses the same
|
| 331 |
+
``DOC_REDACTION_GRADIO_URL`` shown in the Pi UI (not a baked-in HF Space default).
|
| 332 |
+
"""
|
| 333 |
+
from redaction_prompt import doc_redaction_gradio_url
|
| 334 |
+
|
| 335 |
+
url = doc_redaction_gradio_url().strip().rstrip("/")
|
| 336 |
+
config: dict[str, str] = {}
|
| 337 |
+
if url:
|
| 338 |
+
config["DOC_REDACTION_GRADIO_URL"] = url
|
| 339 |
+
for key in (
|
| 340 |
+
"DOC_REDACTION_GRADIO_AUTH_USER",
|
| 341 |
+
"DOC_REDACTION_GRADIO_AUTH_PASSWORD",
|
| 342 |
+
# CloudFront magic-link: the AgentCore runtime runs outside the VPC and
|
| 343 |
+
# reaches doc_redaction through CloudFront, so it must send the token
|
| 344 |
+
# cookie on every request (Service Connect is not reachable from it).
|
| 345 |
+
"DOC_REDACTION_AUTH_TOKEN",
|
| 346 |
+
"DOC_REDACTION_AUTH_COOKIE_NAME",
|
| 347 |
+
"AGENT_DEFAULT_OCR_METHOD",
|
| 348 |
+
"AGENT_DEFAULT_PII_METHOD",
|
| 349 |
+
):
|
| 350 |
+
value = (os.environ.get(key) or "").strip()
|
| 351 |
+
if value:
|
| 352 |
+
config[key] = value
|
| 353 |
+
if "hf.space" in url.lower():
|
| 354 |
+
token = (
|
| 355 |
+
os.environ.get("HF_TOKEN") or os.environ.get("DOC_REDACTION_HF_TOKEN") or ""
|
| 356 |
+
).strip()
|
| 357 |
+
if token:
|
| 358 |
+
config["HF_TOKEN"] = token
|
| 359 |
+
return config
|
agent-redact/agentcore/bundle_support/session_workspace.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio-free session workspace helpers for AgentCore runtime bundles."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
_SESSION_ID_RE = re.compile(r"[^a-zA-Z0-9_@.+-]+")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def workspace_base_dir() -> Path:
|
| 13 |
+
raw = (os.environ.get("AGENT_WORKSPACE_DIR") or "").strip()
|
| 14 |
+
if raw:
|
| 15 |
+
path = Path(raw)
|
| 16 |
+
else:
|
| 17 |
+
path = Path("/tmp/agentcore-workspace")
|
| 18 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
return path.resolve()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def session_workspace_enabled() -> bool:
|
| 23 |
+
raw = os.environ.get("AGENT_SESSION_WORKSPACE", "").strip().lower()
|
| 24 |
+
if raw in {"0", "false", "no", "off"}:
|
| 25 |
+
return False
|
| 26 |
+
return True
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def sanitize_session_id(raw: str) -> str:
|
| 30 |
+
cleaned = _SESSION_ID_RE.sub("_", (raw or "").strip())[:128].strip("_")
|
| 31 |
+
return cleaned or "default"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def session_workspace_dir(session_hash: str) -> Path:
|
| 35 |
+
base = workspace_base_dir().resolve()
|
| 36 |
+
if not session_workspace_enabled():
|
| 37 |
+
return base
|
| 38 |
+
safe_id = sanitize_session_id(session_hash)
|
| 39 |
+
candidate = (base / safe_id).resolve()
|
| 40 |
+
try:
|
| 41 |
+
candidate.relative_to(base)
|
| 42 |
+
except ValueError:
|
| 43 |
+
return (base / "default").resolve()
|
| 44 |
+
return candidate
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def ensure_session_workspace(session_hash: str) -> Path:
|
| 48 |
+
workspace = session_workspace_dir(session_hash)
|
| 49 |
+
workspace.mkdir(parents=True, exist_ok=True)
|
| 50 |
+
return workspace
|
agent-redact/agentcore/entrypoint.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bedrock AgentCore runtime entrypoint wrapping the LangGraph redaction agent."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
_AGENTCORE_DIR = Path(__file__).resolve().parent
|
| 9 |
+
_AGENT_REDACT = _AGENTCORE_DIR.parent
|
| 10 |
+
_REPO_ROOT = _AGENT_REDACT.parent
|
| 11 |
+
for path in (
|
| 12 |
+
_REPO_ROOT,
|
| 13 |
+
_AGENT_REDACT,
|
| 14 |
+
_AGENT_REDACT / "shared",
|
| 15 |
+
_AGENT_REDACT / "pi",
|
| 16 |
+
_AGENTCORE_DIR,
|
| 17 |
+
):
|
| 18 |
+
text = str(path)
|
| 19 |
+
if text not in sys.path:
|
| 20 |
+
sys.path.insert(0, text)
|
| 21 |
+
|
| 22 |
+
from invoke_agent import bootstrap_runtime_env, invoke_redaction_agent # noqa: E402
|
| 23 |
+
|
| 24 |
+
bootstrap_runtime_env(_REPO_ROOT)
|
| 25 |
+
|
| 26 |
+
from bedrock_agentcore import BedrockAgentCoreApp # noqa: E402
|
| 27 |
+
|
| 28 |
+
app = BedrockAgentCoreApp()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@app.entrypoint
|
| 32 |
+
async def handler(request: dict):
|
| 33 |
+
"""Stream LangGraph agent events for one user prompt."""
|
| 34 |
+
async for event in invoke_redaction_agent(request):
|
| 35 |
+
yield event
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
app.run()
|
agent-redact/agentcore/harness_input_bridge.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Upload task inputs for AgentCore Harness (S3 + presigned URL prompt prefix)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from urllib.parse import urlparse
|
| 8 |
+
|
| 9 |
+
from session_workspace import session_workspace_dir
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _parse_s3_uri(uri: str) -> tuple[str, str]:
|
| 13 |
+
parsed = urlparse((uri or "").strip())
|
| 14 |
+
if parsed.scheme != "s3" or not parsed.netloc:
|
| 15 |
+
raise ValueError(f"Invalid S3 URI: {uri!r}")
|
| 16 |
+
prefix = (parsed.path or "").lstrip("/")
|
| 17 |
+
if prefix and not prefix.endswith("/"):
|
| 18 |
+
prefix += "/"
|
| 19 |
+
return parsed.netloc, prefix
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def harness_s3_input_uri(session_hash: str, file_name: str) -> tuple[str, str, str]:
|
| 23 |
+
"""
|
| 24 |
+
Return ``(bucket, key, s3_uri)`` for a harness input object.
|
| 25 |
+
|
| 26 |
+
Uses ``AGENTCORE_HARNESS_S3_INPUT_PREFIX`` (``s3://bucket/prefix/``) when set,
|
| 27 |
+
otherwise ``s3://{S3_OUTPUTS_BUCKET}/harness-inputs/{session_hash}/``.
|
| 28 |
+
"""
|
| 29 |
+
explicit = (os.environ.get("AGENTCORE_HARNESS_S3_INPUT_PREFIX") or "").strip()
|
| 30 |
+
if explicit:
|
| 31 |
+
bucket, prefix = _parse_s3_uri(explicit)
|
| 32 |
+
else:
|
| 33 |
+
bucket = (os.environ.get("S3_OUTPUTS_BUCKET") or "").strip()
|
| 34 |
+
if not bucket:
|
| 35 |
+
raise ValueError(
|
| 36 |
+
"Set AGENTCORE_HARNESS_S3_INPUT_PREFIX or S3_OUTPUTS_BUCKET for harness file upload."
|
| 37 |
+
)
|
| 38 |
+
safe_session = (session_hash or "default").strip().replace("/", "_")[:128]
|
| 39 |
+
prefix = f"harness-inputs/{safe_session}/"
|
| 40 |
+
key = f"{prefix}{Path(file_name).name}"
|
| 41 |
+
return bucket, key, f"s3://{bucket}/{key}"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def build_harness_document_prompt_prefix(
|
| 45 |
+
session_hash: str,
|
| 46 |
+
document_name: str,
|
| 47 |
+
) -> str | None:
|
| 48 |
+
"""
|
| 49 |
+
Upload the task PDF to S3 and return a prompt prefix for the Harness to fetch it.
|
| 50 |
+
|
| 51 |
+
Returns ``None`` when upload is disabled or the file is missing.
|
| 52 |
+
"""
|
| 53 |
+
if not document_name:
|
| 54 |
+
return None
|
| 55 |
+
run_aws = (os.environ.get("RUN_AWS_FUNCTIONS") or "").strip().lower() in {
|
| 56 |
+
"1",
|
| 57 |
+
"true",
|
| 58 |
+
"yes",
|
| 59 |
+
"on",
|
| 60 |
+
}
|
| 61 |
+
if (
|
| 62 |
+
not run_aws
|
| 63 |
+
and not (os.environ.get("AGENTCORE_HARNESS_S3_INPUT_PREFIX") or "").strip()
|
| 64 |
+
):
|
| 65 |
+
return (
|
| 66 |
+
"**Harness file bridge:** RUN_AWS_FUNCTIONS is off and "
|
| 67 |
+
"AGENTCORE_HARNESS_S3_INPUT_PREFIX is unset β upload the document to the Harness "
|
| 68 |
+
"workspace manually or enable S3 upload."
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
root = session_workspace_dir(session_hash)
|
| 72 |
+
src = root / document_name
|
| 73 |
+
if not src.is_file():
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
import boto3
|
| 78 |
+
from botocore.exceptions import BotoCoreError, ClientError
|
| 79 |
+
from pi_agent_config import configure_aws_credentials
|
| 80 |
+
|
| 81 |
+
configure_aws_credentials()
|
| 82 |
+
bucket, key, s3_uri = harness_s3_input_uri(session_hash, document_name)
|
| 83 |
+
region = (
|
| 84 |
+
os.environ.get("AWS_REGION")
|
| 85 |
+
or os.environ.get("AWS_DEFAULT_REGION")
|
| 86 |
+
or "eu-west-2"
|
| 87 |
+
)
|
| 88 |
+
client = boto3.client("s3", region_name=region)
|
| 89 |
+
client.upload_file(str(src), bucket, key)
|
| 90 |
+
presigned = client.generate_presigned_url(
|
| 91 |
+
"get_object",
|
| 92 |
+
Params={"Bucket": bucket, "Key": key},
|
| 93 |
+
ExpiresIn=int(os.environ.get("AGENTCORE_HARNESS_PRESIGN_SECONDS", "3600")),
|
| 94 |
+
)
|
| 95 |
+
except (BotoCoreError, ClientError, ValueError, OSError) as exc:
|
| 96 |
+
return (
|
| 97 |
+
f"**Harness file bridge error:** Could not upload `{document_name}` to S3 ({exc}). "
|
| 98 |
+
"Place the file on the Harness workspace mount or fix AWS permissions."
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
mount_path = (
|
| 102 |
+
os.environ.get("AGENTCORE_HARNESS_S3_MOUNT_PATH") or "/tmp/workspace"
|
| 103 |
+
).rstrip("/")
|
| 104 |
+
dest = f"{mount_path}/{Path(document_name).name}"
|
| 105 |
+
return (
|
| 106 |
+
f"**Harness input file (download before Pass 1):**\n"
|
| 107 |
+
f"- S3 object: `{s3_uri}`\n"
|
| 108 |
+
f"- Presigned URL (expires in 1h): {presigned}\n"
|
| 109 |
+
f"- Save to Harness workspace as: `{dest}`\n"
|
| 110 |
+
f"- Example: `curl -fsSL -o {dest!r} '<presigned-url>'` then use `{dest}` as INPUT_PATH.\n"
|
| 111 |
+
)
|
agent-redact/agentcore/invoke_agent.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared AgentCore invoke logic (monorepo entrypoint + packaged runtime)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
from collections.abc import AsyncIterator
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from session_store import (
|
| 11 |
+
append_turn,
|
| 12 |
+
clear_session,
|
| 13 |
+
get_messages,
|
| 14 |
+
stringify_message_content,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def configure_import_paths(app_root: Path | None = None) -> tuple[Path, Path]:
|
| 19 |
+
"""
|
| 20 |
+
Ensure imports resolve in the monorepo or a packaged AgentCore app folder.
|
| 21 |
+
|
| 22 |
+
Returns ``(repo_root, agent_redact_root)`` for bootstrap_pi_config.
|
| 23 |
+
"""
|
| 24 |
+
root = (app_root or Path(__file__).resolve().parent).resolve()
|
| 25 |
+
agent_redact = root
|
| 26 |
+
shared_dir = root / "shared"
|
| 27 |
+
for path in (root, agent_redact, shared_dir):
|
| 28 |
+
text = str(path)
|
| 29 |
+
if text not in sys.path:
|
| 30 |
+
sys.path.insert(0, text)
|
| 31 |
+
|
| 32 |
+
repo_root = root
|
| 33 |
+
if (root / "agent-redact").is_dir():
|
| 34 |
+
repo_root = root
|
| 35 |
+
agent_redact = root / "agent-redact"
|
| 36 |
+
elif (
|
| 37 |
+
root.name == "RedactionAgent" and (root.parent.parent / "agent-redact").is_dir()
|
| 38 |
+
):
|
| 39 |
+
repo_root = root.parent.parent.parent
|
| 40 |
+
agent_redact = repo_root / "agent-redact"
|
| 41 |
+
return repo_root, agent_redact
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def bootstrap_runtime_env(app_root: Path) -> None:
|
| 45 |
+
"""
|
| 46 |
+
Lightweight env setup for AgentCore (no Pi skills sync or monorepo ``tools/``).
|
| 47 |
+
|
| 48 |
+
Full :func:`bootstrap_pi_config.ensure_pi_config_env` pulls in ``pi_workspace_skills``
|
| 49 |
+
and repo ``skills/`` β not vendored in the CodeZip bundle and will fail or stall
|
| 50 |
+
runtime init on AWS.
|
| 51 |
+
"""
|
| 52 |
+
from dotenv import load_dotenv
|
| 53 |
+
|
| 54 |
+
root = app_root.resolve()
|
| 55 |
+
for env_name in ("agentcore.env", ".env"):
|
| 56 |
+
env_file = root / env_name
|
| 57 |
+
if env_file.is_file():
|
| 58 |
+
load_dotenv(env_file, override=False)
|
| 59 |
+
|
| 60 |
+
os.environ.setdefault("AGENT_WORKSPACE_DIR", "/tmp/agentcore-workspace")
|
| 61 |
+
os.environ.setdefault("AGENT_REDACTION_SPLIT_BACKEND", "true")
|
| 62 |
+
os.environ.setdefault("AGENT_DEFAULT_PROVIDER", "amazon-bedrock")
|
| 63 |
+
os.environ.setdefault("AGENT_DEFAULT_MODEL", "anthropic.claude-sonnet-4-6")
|
| 64 |
+
os.environ.setdefault(
|
| 65 |
+
"AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "eu-west-2")
|
| 66 |
+
)
|
| 67 |
+
os.environ.setdefault("AWS_DEFAULT_REGION", os.environ["AWS_REGION"])
|
| 68 |
+
Path(os.environ["AGENT_WORKSPACE_DIR"]).mkdir(parents=True, exist_ok=True)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
INVOKE_RUNTIME_CONFIG_KEYS = frozenset(
|
| 72 |
+
{
|
| 73 |
+
"DOC_REDACTION_GRADIO_URL",
|
| 74 |
+
"DOC_REDACTION_GRADIO_AUTH_USER",
|
| 75 |
+
"DOC_REDACTION_GRADIO_AUTH_PASSWORD",
|
| 76 |
+
# CloudFront magic-link cookie: the AgentCore runtime runs outside the VPC
|
| 77 |
+
# and reaches doc_redaction through CloudFront, so the token forwarded by
|
| 78 |
+
# build_agentcore_invoke_runtime_config must be applied here or every
|
| 79 |
+
# backend request hits the login wall ("credentials were not provided").
|
| 80 |
+
"DOC_REDACTION_AUTH_TOKEN",
|
| 81 |
+
"DOC_REDACTION_AUTH_COOKIE_NAME",
|
| 82 |
+
"AGENT_DEFAULT_OCR_METHOD",
|
| 83 |
+
"AGENT_DEFAULT_PII_METHOD",
|
| 84 |
+
"HF_TOKEN",
|
| 85 |
+
"DOC_REDACTION_HF_TOKEN",
|
| 86 |
+
}
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def apply_invoke_runtime_config(request: dict) -> None:
|
| 91 |
+
"""
|
| 92 |
+
Apply per-invoke backend settings from the Gradio UI (overrides agentcore.env).
|
| 93 |
+
|
| 94 |
+
The AgentCore runtime on AWS has its own ``agentcore.env``; without this, a
|
| 95 |
+
deployed HF Space URL can win over the operator's local ``agent.env``.
|
| 96 |
+
"""
|
| 97 |
+
raw = request.get("runtime_config") or request.get("runtime_env") or {}
|
| 98 |
+
if not isinstance(raw, dict):
|
| 99 |
+
return
|
| 100 |
+
for key in INVOKE_RUNTIME_CONFIG_KEYS:
|
| 101 |
+
value = raw.get(key)
|
| 102 |
+
if value is None:
|
| 103 |
+
continue
|
| 104 |
+
text = str(value).strip()
|
| 105 |
+
if text:
|
| 106 |
+
os.environ[key] = text
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def invoke_redaction_agent(request: dict) -> AsyncIterator[dict]:
|
| 110 |
+
"""Stream LangGraph agent events for one user prompt (multi-turn per session_hash)."""
|
| 111 |
+
# Must run before LangChain imports (OpenInference patch order).
|
| 112 |
+
try:
|
| 113 |
+
from eval.arize_monitoring import setup_arize_ax_tracing
|
| 114 |
+
|
| 115 |
+
setup_arize_ax_tracing()
|
| 116 |
+
except ImportError:
|
| 117 |
+
pass
|
| 118 |
+
|
| 119 |
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
| 120 |
+
from workspace_sync import (
|
| 121 |
+
apply_workspace_files,
|
| 122 |
+
collect_workspace_files_for_sync,
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
apply_invoke_runtime_config(request)
|
| 126 |
+
|
| 127 |
+
prompt = str(request.get("prompt") or request.get("message") or "").strip()
|
| 128 |
+
session_hash = str(request.get("session_hash") or "").strip() or None
|
| 129 |
+
if request.get("new_session"):
|
| 130 |
+
clear_session(session_hash)
|
| 131 |
+
|
| 132 |
+
if not prompt:
|
| 133 |
+
yield {"type": "error", "message": "prompt is required"}
|
| 134 |
+
return
|
| 135 |
+
|
| 136 |
+
incoming_files = request.get("workspace_files") or []
|
| 137 |
+
if isinstance(incoming_files, list) and incoming_files:
|
| 138 |
+
written = apply_workspace_files(session_hash, incoming_files)
|
| 139 |
+
if written:
|
| 140 |
+
yield {
|
| 141 |
+
"type": "status",
|
| 142 |
+
"message": f"Synced {len(written)} file(s) into AgentCore workspace.",
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
backend_url = (os.environ.get("DOC_REDACTION_GRADIO_URL") or "").strip().rstrip("/")
|
| 146 |
+
if backend_url:
|
| 147 |
+
yield {
|
| 148 |
+
"type": "status",
|
| 149 |
+
"message": f"Redaction backend for this turn: {backend_url}",
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
from eval.arize_monitoring import arize_session_context, langgraph_trace_config
|
| 153 |
+
from redaction_langgraph.graph import build_redaction_agent, graph_recursion_limit
|
| 154 |
+
from redaction_langgraph.llm_errors import (
|
| 155 |
+
is_context_overflow_error,
|
| 156 |
+
is_tool_call_json_parse_error,
|
| 157 |
+
)
|
| 158 |
+
from redaction_langgraph.message_context import (
|
| 159 |
+
get_trim_stats,
|
| 160 |
+
reset_trim_stats,
|
| 161 |
+
set_aggressive_trim,
|
| 162 |
+
)
|
| 163 |
+
from redaction_langgraph.workflow_continue import build_tool_call_json_retry_prompt
|
| 164 |
+
|
| 165 |
+
graph, system_message = build_redaction_agent(session_hash)
|
| 166 |
+
prior = get_messages(session_hash)
|
| 167 |
+
inputs = {"messages": [system_message, *prior, HumanMessage(content=prompt)]}
|
| 168 |
+
yield {"type": "agent_start"}
|
| 169 |
+
|
| 170 |
+
assistant_chunks: list[str] = []
|
| 171 |
+
stream_config = langgraph_trace_config(
|
| 172 |
+
session_hash, recursion_limit=graph_recursion_limit()
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
def _emit_stream(active_graph, active_inputs) -> list[dict]:
|
| 176 |
+
"""Collect stream events; callers yield them and handle errors."""
|
| 177 |
+
out: list[dict] = []
|
| 178 |
+
reset_trim_stats()
|
| 179 |
+
compaction_noted = False
|
| 180 |
+
for event in active_graph.stream(
|
| 181 |
+
active_inputs, stream_mode="updates", config=stream_config
|
| 182 |
+
):
|
| 183 |
+
stats = get_trim_stats()
|
| 184 |
+
if stats is not None and stats.trimmed and not compaction_noted:
|
| 185 |
+
compaction_noted = True
|
| 186 |
+
out.append(
|
| 187 |
+
{
|
| 188 |
+
"type": "status",
|
| 189 |
+
"message": (
|
| 190 |
+
f"Context compaction ({stats.tokens_before:,} β "
|
| 191 |
+
f"{stats.tokens_after:,} tokens)."
|
| 192 |
+
),
|
| 193 |
+
}
|
| 194 |
+
)
|
| 195 |
+
for node, update in event.items():
|
| 196 |
+
messages = update.get("messages") or []
|
| 197 |
+
for message in messages:
|
| 198 |
+
if isinstance(message, AIMessage):
|
| 199 |
+
text = stringify_message_content(message.content)
|
| 200 |
+
if text:
|
| 201 |
+
assistant_chunks.append(text)
|
| 202 |
+
out.append(
|
| 203 |
+
{
|
| 204 |
+
"type": "message_update",
|
| 205 |
+
"node": node,
|
| 206 |
+
"role": "assistant",
|
| 207 |
+
"content": text,
|
| 208 |
+
"tool_calls": message.tool_calls or [],
|
| 209 |
+
}
|
| 210 |
+
)
|
| 211 |
+
elif isinstance(message, ToolMessage):
|
| 212 |
+
out.append(
|
| 213 |
+
{
|
| 214 |
+
"type": "message_update",
|
| 215 |
+
"node": node,
|
| 216 |
+
"role": "tool",
|
| 217 |
+
"content": stringify_message_content(message.content),
|
| 218 |
+
"tool_name": str(message.name or "tool"),
|
| 219 |
+
}
|
| 220 |
+
)
|
| 221 |
+
else:
|
| 222 |
+
content = getattr(message, "content", "")
|
| 223 |
+
out.append(
|
| 224 |
+
{
|
| 225 |
+
"type": "message_update",
|
| 226 |
+
"node": node,
|
| 227 |
+
"role": getattr(message, "type", "unknown"),
|
| 228 |
+
"content": content,
|
| 229 |
+
}
|
| 230 |
+
)
|
| 231 |
+
return out
|
| 232 |
+
|
| 233 |
+
try:
|
| 234 |
+
with arize_session_context(session_hash):
|
| 235 |
+
try:
|
| 236 |
+
for item in _emit_stream(graph, inputs):
|
| 237 |
+
yield item
|
| 238 |
+
except Exception as exc:
|
| 239 |
+
if is_context_overflow_error(exc):
|
| 240 |
+
assistant_chunks.clear()
|
| 241 |
+
yield {
|
| 242 |
+
"type": "status",
|
| 243 |
+
"message": (
|
| 244 |
+
"Prompt exceeded model context β retrying once with "
|
| 245 |
+
"aggressive compactionβ¦"
|
| 246 |
+
),
|
| 247 |
+
}
|
| 248 |
+
set_aggressive_trim(True)
|
| 249 |
+
try:
|
| 250 |
+
graph, _ = build_redaction_agent(
|
| 251 |
+
session_hash, aggressive_compaction=True
|
| 252 |
+
)
|
| 253 |
+
for item in _emit_stream(graph, inputs):
|
| 254 |
+
yield item
|
| 255 |
+
finally:
|
| 256 |
+
set_aggressive_trim(False)
|
| 257 |
+
elif is_tool_call_json_parse_error(exc):
|
| 258 |
+
assistant_chunks.clear()
|
| 259 |
+
yield {
|
| 260 |
+
"type": "status",
|
| 261 |
+
"message": (
|
| 262 |
+
"Tool-call JSON was truncated or invalid β retrying once "
|
| 263 |
+
"with a compact-script nudgeβ¦"
|
| 264 |
+
),
|
| 265 |
+
}
|
| 266 |
+
retry_inputs = {
|
| 267 |
+
"messages": [
|
| 268 |
+
*(inputs.get("messages") or []),
|
| 269 |
+
HumanMessage(content=build_tool_call_json_retry_prompt()),
|
| 270 |
+
]
|
| 271 |
+
}
|
| 272 |
+
for item in _emit_stream(graph, retry_inputs):
|
| 273 |
+
yield item
|
| 274 |
+
else:
|
| 275 |
+
raise
|
| 276 |
+
except Exception as exc:
|
| 277 |
+
yield {"type": "error", "message": f"LangGraph agent failed: {exc}"}
|
| 278 |
+
return
|
| 279 |
+
|
| 280 |
+
append_turn(
|
| 281 |
+
session_hash,
|
| 282 |
+
user_text=prompt,
|
| 283 |
+
assistant_text="\n".join(assistant_chunks),
|
| 284 |
+
)
|
| 285 |
+
if request.get("sync_workspace_files"):
|
| 286 |
+
for item in collect_workspace_files_for_sync(session_hash):
|
| 287 |
+
yield {"type": "workspace_file", **item}
|
| 288 |
+
yield {"type": "agent_end", "message": "Agent finished."}
|
agent-redact/agentcore/package_runtime.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Package doc_redaction LangGraph agent code into an AgentCore app folder."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import shutil
|
| 10 |
+
import stat
|
| 11 |
+
import subprocess
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import tomllib
|
| 15 |
+
|
| 16 |
+
_COPY_IGNORE = shutil.ignore_patterns(
|
| 17 |
+
"__pycache__", "*.pyc", ".pytest_cache", ".mypy_cache"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
# Runtime Python deps to merge into the AgentCore app's pyproject.toml (not full pi-agent stack).
|
| 21 |
+
RUNTIME_DEPENDENCIES: dict[str, str] = {
|
| 22 |
+
"gradio_client": ">=1.0.0",
|
| 23 |
+
"httpx": ">=0.28.0",
|
| 24 |
+
"python-dotenv": ">=1.0.0",
|
| 25 |
+
"langchain-openai": ">=1.0.0",
|
| 26 |
+
"langchain-core": ">=1.0.0",
|
| 27 |
+
"langgraph": ">=1.0.2",
|
| 28 |
+
"langchain-aws": ">=1.0.0",
|
| 29 |
+
"pymupdf": ">=1.24.0",
|
| 30 |
+
"pandas": ">=2.0.0",
|
| 31 |
+
"arize-otel": ">=0.9.0",
|
| 32 |
+
"arize-phoenix-otel": ">=0.16.0",
|
| 33 |
+
"openinference-instrumentation-langchain": ">=0.1.0",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
MAIN_PY = '''"""doc_redaction LangGraph agent β packaged by agent-redact/agentcore/package_runtime.py."""
|
| 37 |
+
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
import sys
|
| 41 |
+
from pathlib import Path
|
| 42 |
+
|
| 43 |
+
_APP_ROOT = Path(__file__).resolve().parent
|
| 44 |
+
_SHARED_DIR = _APP_ROOT / "shared"
|
| 45 |
+
for path in (_APP_ROOT, _SHARED_DIR):
|
| 46 |
+
text = str(path)
|
| 47 |
+
if text not in sys.path:
|
| 48 |
+
sys.path.insert(0, text)
|
| 49 |
+
|
| 50 |
+
from invoke_agent import bootstrap_runtime_env, invoke_redaction_agent # noqa: E402
|
| 51 |
+
|
| 52 |
+
bootstrap_runtime_env(_APP_ROOT)
|
| 53 |
+
|
| 54 |
+
from bedrock_agentcore import BedrockAgentCoreApp # noqa: E402
|
| 55 |
+
|
| 56 |
+
app = BedrockAgentCoreApp()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@app.entrypoint
|
| 60 |
+
async def handler(request: dict):
|
| 61 |
+
async for event in invoke_redaction_agent(request):
|
| 62 |
+
yield event
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
if __name__ == "__main__":
|
| 66 |
+
app.run()
|
| 67 |
+
'''
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _repo_root() -> Path:
|
| 71 |
+
return Path(__file__).resolve().parents[2]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _default_agentcore_app() -> Path:
|
| 75 |
+
return _repo_root() / "agent-redact" / "RedactionAgent" / "app" / "RedactionAgent"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _rmtree_robust(path: Path) -> None:
|
| 79 |
+
"""Remove a directory tree on Windows / OneDrive (clears read-only files first)."""
|
| 80 |
+
|
| 81 |
+
def _on_rm_error(func, location, _exc_info) -> None:
|
| 82 |
+
os.chmod(location, stat.S_IWRITE)
|
| 83 |
+
func(location)
|
| 84 |
+
|
| 85 |
+
shutil.rmtree(path, onerror=_on_rm_error)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _copy_tree(src: Path, dest: Path, *, dry_run: bool) -> None:
|
| 89 |
+
if dry_run:
|
| 90 |
+
print(f" copy tree {src} -> {dest}")
|
| 91 |
+
return
|
| 92 |
+
if dest.exists():
|
| 93 |
+
_rmtree_robust(dest)
|
| 94 |
+
shutil.copytree(src, dest, ignore=_COPY_IGNORE)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _copy_file(src: Path, dest: Path, *, dry_run: bool) -> None:
|
| 98 |
+
if dry_run:
|
| 99 |
+
print(f" copy file {src} -> {dest}")
|
| 100 |
+
return
|
| 101 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 102 |
+
shutil.copy2(src, dest)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _replace_dependencies_block(text: str, deps: list[str]) -> str:
|
| 106 |
+
lines = text.splitlines()
|
| 107 |
+
out: list[str] = []
|
| 108 |
+
index = 0
|
| 109 |
+
while index < len(lines):
|
| 110 |
+
if lines[index].strip().startswith("dependencies"):
|
| 111 |
+
out.append("dependencies = [")
|
| 112 |
+
for dep in deps:
|
| 113 |
+
out.append(f' "{dep}",')
|
| 114 |
+
out.append("]")
|
| 115 |
+
index += 1
|
| 116 |
+
while index < len(lines) and lines[index].strip() != "]":
|
| 117 |
+
index += 1
|
| 118 |
+
index += 1
|
| 119 |
+
continue
|
| 120 |
+
out.append(lines[index])
|
| 121 |
+
index += 1
|
| 122 |
+
return "\n".join(out) + ("\n" if text.endswith("\n") else "")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _merge_pyproject(pyproject_path: Path, *, dry_run: bool) -> None:
|
| 126 |
+
text = pyproject_path.read_text(encoding="utf-8")
|
| 127 |
+
if dry_run:
|
| 128 |
+
print(f" merge deps into {pyproject_path}")
|
| 129 |
+
return
|
| 130 |
+
try:
|
| 131 |
+
data = tomllib.loads(text)
|
| 132 |
+
except tomllib.TOMLDecodeError as exc:
|
| 133 |
+
raise SystemExit(f"Could not parse {pyproject_path}: {exc}") from exc
|
| 134 |
+
|
| 135 |
+
existing: dict[str, str] = {}
|
| 136 |
+
for item in data.get("project", {}).get("dependencies", []):
|
| 137 |
+
if isinstance(item, str):
|
| 138 |
+
name = re.split(r"[<>=!~\[]", item, maxsplit=1)[0].strip()
|
| 139 |
+
existing[name.lower()] = item
|
| 140 |
+
|
| 141 |
+
for name, spec in RUNTIME_DEPENDENCIES.items():
|
| 142 |
+
key = name.lower()
|
| 143 |
+
if key not in existing:
|
| 144 |
+
existing[key] = f"{name}{spec}"
|
| 145 |
+
|
| 146 |
+
merged = [existing[k] for k in sorted(existing, key=str.lower)]
|
| 147 |
+
pyproject_path.write_text(
|
| 148 |
+
_replace_dependencies_block(text, merged), encoding="utf-8"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def package_runtime(
|
| 153 |
+
target: Path,
|
| 154 |
+
*,
|
| 155 |
+
dry_run: bool = False,
|
| 156 |
+
) -> list[str]:
|
| 157 |
+
"""Sync monorepo redaction agent sources into *target* (AgentCore app folder)."""
|
| 158 |
+
repo = _repo_root()
|
| 159 |
+
agent_redact = repo / "agent-redact"
|
| 160 |
+
agentcore = agent_redact / "agentcore"
|
| 161 |
+
actions: list[str] = []
|
| 162 |
+
|
| 163 |
+
def log(msg: str) -> None:
|
| 164 |
+
actions.append(msg)
|
| 165 |
+
print(msg)
|
| 166 |
+
|
| 167 |
+
log(f"Packaging doc_redaction runtime -> {target}")
|
| 168 |
+
|
| 169 |
+
_copy_tree(
|
| 170 |
+
agent_redact / "redaction_langgraph",
|
| 171 |
+
target / "redaction_langgraph",
|
| 172 |
+
dry_run=dry_run,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
_copy_tree(
|
| 176 |
+
agent_redact / "eval",
|
| 177 |
+
target / "eval",
|
| 178 |
+
dry_run=dry_run,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
shared_dest = target / "shared"
|
| 182 |
+
for name in ("remote_redaction.py",):
|
| 183 |
+
_copy_file(agent_redact / "shared" / name, shared_dest / name, dry_run=dry_run)
|
| 184 |
+
|
| 185 |
+
_copy_file(
|
| 186 |
+
agentcore / "bundle_support" / "session_workspace.py",
|
| 187 |
+
shared_dest / "session_workspace.py",
|
| 188 |
+
dry_run=dry_run,
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
for module in ("invoke_agent.py", "session_store.py", "workspace_sync.py"):
|
| 192 |
+
_copy_file(agentcore / module, target / module, dry_run=dry_run)
|
| 193 |
+
|
| 194 |
+
if dry_run:
|
| 195 |
+
log(f" write {target / 'main.py'}")
|
| 196 |
+
else:
|
| 197 |
+
(target / "main.py").write_text(MAIN_PY, encoding="utf-8")
|
| 198 |
+
log(f"wrote {target / 'main.py'}")
|
| 199 |
+
|
| 200 |
+
pyproject = target / "pyproject.toml"
|
| 201 |
+
if pyproject.is_file():
|
| 202 |
+
_merge_pyproject(pyproject, dry_run=dry_run)
|
| 203 |
+
log(f"merged runtime dependencies into {pyproject}")
|
| 204 |
+
elif dry_run:
|
| 205 |
+
log(f" skip pyproject merge (no {pyproject} β run agentcore create first)")
|
| 206 |
+
else:
|
| 207 |
+
raise SystemExit(f"Missing {pyproject} β run agentcore create first.")
|
| 208 |
+
|
| 209 |
+
env_example = target / "agentcore.env.example"
|
| 210 |
+
env_local = target / "agentcore.env"
|
| 211 |
+
example_text = """# Loaded at runtime startup when present in the CodeZip (see invoke_agent.bootstrap_runtime_env).
|
| 212 |
+
# Also set these on the AgentCore runtime in AWS if you prefer console/config-bundle env.
|
| 213 |
+
# CDK + AgentCore: use main Express HTTPS (ExpressServiceEndpoint), not Service Connect.
|
| 214 |
+
DOC_REDACTION_GRADIO_URL=https://your-doc-redaction-host.example
|
| 215 |
+
AGENT_DEFAULT_PROVIDER=amazon-bedrock
|
| 216 |
+
AGENT_DEFAULT_MODEL=anthropic.claude-sonnet-4-6
|
| 217 |
+
AWS_REGION=eu-west-2
|
| 218 |
+
AGENT_WORKSPACE_DIR=/tmp/agentcore-workspace
|
| 219 |
+
AGENT_DEFAULT_OCR_METHOD=paddle
|
| 220 |
+
AGENT_DEFAULT_PII_METHOD=Local
|
| 221 |
+
# Optional Arize AX / Phoenix tracing for in-process LangGraph
|
| 222 |
+
# (see agent-redact/eval/arize_monitoring.py).
|
| 223 |
+
# ARIZE_TRACING_ENABLED=true
|
| 224 |
+
# ARIZE_BACKEND=ax
|
| 225 |
+
# ARIZE_BACKEND=phoenix
|
| 226 |
+
# PHOENIX_COLLECTOR_ENDPOINT=http://localhost:6006
|
| 227 |
+
# ARIZE_SPACE_ID=
|
| 228 |
+
# ARIZE_API_KEY=
|
| 229 |
+
# ARIZE_PROJECT_NAME=doc-redaction-langgraph
|
| 230 |
+
# ARIZE_ENDPOINT=europe
|
| 231 |
+
"""
|
| 232 |
+
if dry_run:
|
| 233 |
+
log(f" write {env_example}")
|
| 234 |
+
if env_local.is_file():
|
| 235 |
+
log(f" keep existing {env_local}")
|
| 236 |
+
else:
|
| 237 |
+
env_example.write_text(example_text, encoding="utf-8")
|
| 238 |
+
log(f"wrote {env_example}")
|
| 239 |
+
if not env_local.is_file():
|
| 240 |
+
env_local.write_text(example_text, encoding="utf-8")
|
| 241 |
+
log(f"wrote {env_local} (copy from example β edit before deploy)")
|
| 242 |
+
|
| 243 |
+
return actions
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def run_deploy(agentcore_project: Path) -> None:
|
| 247 |
+
env = dict(**{k: v for k, v in __import__("os").environ.items()})
|
| 248 |
+
env.setdefault("UV_LINK_MODE", "copy")
|
| 249 |
+
subprocess.run(
|
| 250 |
+
["agentcore", "deploy"],
|
| 251 |
+
cwd=str(agentcore_project),
|
| 252 |
+
check=True,
|
| 253 |
+
env=env,
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def main(argv: list[str] | None = None) -> int:
|
| 258 |
+
parser = argparse.ArgumentParser(
|
| 259 |
+
description="Package doc_redaction LangGraph agent into an AgentCore app folder.",
|
| 260 |
+
)
|
| 261 |
+
parser.add_argument(
|
| 262 |
+
"--target",
|
| 263 |
+
type=Path,
|
| 264 |
+
default=_default_agentcore_app(),
|
| 265 |
+
help="AgentCore app folder (default: agent-redact/RedactionAgent/app/RedactionAgent)",
|
| 266 |
+
)
|
| 267 |
+
parser.add_argument(
|
| 268 |
+
"--dry-run",
|
| 269 |
+
action="store_true",
|
| 270 |
+
help="Print actions without writing files",
|
| 271 |
+
)
|
| 272 |
+
parser.add_argument(
|
| 273 |
+
"--deploy",
|
| 274 |
+
action="store_true",
|
| 275 |
+
help="Run agentcore deploy from the RedactionAgent project after packaging",
|
| 276 |
+
)
|
| 277 |
+
args = parser.parse_args(argv)
|
| 278 |
+
|
| 279 |
+
target = args.target.resolve()
|
| 280 |
+
package_runtime(target, dry_run=args.dry_run)
|
| 281 |
+
|
| 282 |
+
if args.deploy:
|
| 283 |
+
if args.dry_run:
|
| 284 |
+
print("Skipping deploy (--dry-run).")
|
| 285 |
+
return 0
|
| 286 |
+
project = target.parent.parent
|
| 287 |
+
if not (project / "agentcore" / "agentcore.json").is_file():
|
| 288 |
+
raise SystemExit(f"Not an AgentCore project: {project}")
|
| 289 |
+
print(f"Running agentcore deploy in {project} ...")
|
| 290 |
+
run_deploy(project)
|
| 291 |
+
|
| 292 |
+
print("Done.")
|
| 293 |
+
return 0
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
if __name__ == "__main__":
|
| 297 |
+
raise SystemExit(main())
|
agent-redact/agentcore/session_store.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-process conversation history for AgentCore / LangGraph orchestration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import threading
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
| 9 |
+
|
| 10 |
+
_lock = threading.Lock()
|
| 11 |
+
_sessions: dict[str, list[BaseMessage]] = {}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _session_key(session_hash: str | None) -> str:
|
| 15 |
+
key = (session_hash or "").strip()
|
| 16 |
+
return key or "default"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_messages(session_hash: str | None) -> list[BaseMessage]:
|
| 20 |
+
"""Return a copy of stored messages for *session_hash*."""
|
| 21 |
+
key = _session_key(session_hash)
|
| 22 |
+
with _lock:
|
| 23 |
+
return list(_sessions.get(key, []))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def clear_session(session_hash: str | None) -> None:
|
| 27 |
+
"""Drop conversation history for *session_hash*."""
|
| 28 |
+
key = _session_key(session_hash)
|
| 29 |
+
with _lock:
|
| 30 |
+
_sessions.pop(key, None)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def append_turn(
|
| 34 |
+
session_hash: str | None,
|
| 35 |
+
*,
|
| 36 |
+
user_text: str,
|
| 37 |
+
assistant_text: str = "",
|
| 38 |
+
) -> None:
|
| 39 |
+
"""Append one user turn and optional assistant reply."""
|
| 40 |
+
key = _session_key(session_hash)
|
| 41 |
+
with _lock:
|
| 42 |
+
history = _sessions.setdefault(key, [])
|
| 43 |
+
history.append(HumanMessage(content=user_text))
|
| 44 |
+
if assistant_text.strip():
|
| 45 |
+
history.append(AIMessage(content=assistant_text.strip()))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def stringify_message_content(content: Any) -> str:
|
| 49 |
+
"""Normalize LangChain message content to plain text."""
|
| 50 |
+
if isinstance(content, str):
|
| 51 |
+
return content
|
| 52 |
+
if isinstance(content, list):
|
| 53 |
+
parts: list[str] = []
|
| 54 |
+
for block in content:
|
| 55 |
+
if isinstance(block, str):
|
| 56 |
+
parts.append(block)
|
| 57 |
+
elif isinstance(block, dict) and block.get("type") == "text":
|
| 58 |
+
parts.append(str(block.get("text") or ""))
|
| 59 |
+
return "".join(parts)
|
| 60 |
+
return str(content or "")
|
agent-redact/agentcore/workspace_sync.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sync session workspace files to/from AgentCore invoke payloads."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
_DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def max_workspace_sync_bytes() -> int:
|
| 13 |
+
raw = (os.environ.get("AGENTCORE_MAX_UPLOAD_BYTES") or "").strip()
|
| 14 |
+
if raw.isdigit():
|
| 15 |
+
return int(raw)
|
| 16 |
+
return _DEFAULT_MAX_BYTES
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _ensure_session_workspace(session_hash: str | None) -> Path:
|
| 20 |
+
try:
|
| 21 |
+
from bundle_support.session_workspace import ensure_session_workspace
|
| 22 |
+
except ImportError:
|
| 23 |
+
from session_workspace import (
|
| 24 |
+
ensure_session_workspace, # type: ignore[no-redef]
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
return ensure_session_workspace(session_hash or "")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def apply_workspace_files(session_hash: str | None, files: list[dict]) -> list[str]:
|
| 31 |
+
"""Write base64-encoded files into the session workspace. Returns relative paths written."""
|
| 32 |
+
if not files:
|
| 33 |
+
return []
|
| 34 |
+
root = _ensure_session_workspace(session_hash).resolve()
|
| 35 |
+
written: list[str] = []
|
| 36 |
+
for item in files:
|
| 37 |
+
if not isinstance(item, dict):
|
| 38 |
+
continue
|
| 39 |
+
relative = str(item.get("relative_path") or item.get("name") or "").strip()
|
| 40 |
+
encoded = str(item.get("content_base64") or "").strip()
|
| 41 |
+
if not relative or not encoded:
|
| 42 |
+
continue
|
| 43 |
+
dest = (root / relative).resolve()
|
| 44 |
+
try:
|
| 45 |
+
dest.relative_to(root)
|
| 46 |
+
except ValueError:
|
| 47 |
+
continue
|
| 48 |
+
payload = base64.b64decode(encoded, validate=True)
|
| 49 |
+
if len(payload) > max_workspace_sync_bytes():
|
| 50 |
+
continue
|
| 51 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
dest.write_bytes(payload)
|
| 53 |
+
written.append(str(dest.relative_to(root)).replace("\\", "/"))
|
| 54 |
+
return written
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def collect_workspace_files_for_sync(
|
| 58 |
+
session_hash: str | None,
|
| 59 |
+
*,
|
| 60 |
+
prefix: str = "redact/",
|
| 61 |
+
) -> list[dict[str, str]]:
|
| 62 |
+
"""Collect workspace files under *prefix* for download to the Gradio client."""
|
| 63 |
+
root = _ensure_session_workspace(session_hash).resolve()
|
| 64 |
+
if not root.is_dir():
|
| 65 |
+
return []
|
| 66 |
+
limit = max_workspace_sync_bytes()
|
| 67 |
+
out: list[dict[str, str]] = []
|
| 68 |
+
for path in sorted(root.rglob("*")):
|
| 69 |
+
if not path.is_file():
|
| 70 |
+
continue
|
| 71 |
+
rel = str(path.relative_to(root)).replace("\\", "/")
|
| 72 |
+
if prefix and not rel.startswith(prefix.lstrip("/")):
|
| 73 |
+
continue
|
| 74 |
+
size = path.stat().st_size
|
| 75 |
+
if size > limit:
|
| 76 |
+
continue
|
| 77 |
+
out.append(
|
| 78 |
+
{
|
| 79 |
+
"relative_path": rel,
|
| 80 |
+
"content_base64": base64.b64encode(path.read_bytes()).decode("ascii"),
|
| 81 |
+
}
|
| 82 |
+
)
|
| 83 |
+
return out
|
agent-redact/eval/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Agent evaluation and observability helpers (e.g. Arize AX / Phoenix tracing)."""
|
agent-redact/eval/arize_monitoring.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Arize / Phoenix OpenTelemetry tracing for LangGraph agent runs.
|
| 2 |
+
|
| 3 |
+
Call :func:`setup_arize_ax_tracing` once **before** any ``langchain`` /
|
| 4 |
+
``langchain_core`` import so ``LangChainInstrumentor`` can patch the stack.
|
| 5 |
+
|
| 6 |
+
Multi-turn chats: wrap each ``graph.stream`` with :func:`arize_session_context`
|
| 7 |
+
and pass :func:`langgraph_trace_config` so turns share ``session.id`` (Gradio
|
| 8 |
+
``session_hash``) and appear under one Sessions row in Arize AX or Phoenix.
|
| 9 |
+
|
| 10 |
+
Environment (see ``config/agent.env.example``):
|
| 11 |
+
|
| 12 |
+
- ``ARIZE_TRACING_ENABLED`` β set ``true`` / ``1`` / ``yes`` / ``on`` to enable
|
| 13 |
+
- ``ARIZE_BACKEND`` β ``ax`` (default, hosted Arize AX) or ``phoenix`` (local /
|
| 14 |
+
self-hosted Phoenix)
|
| 15 |
+
- ``ARIZE_PROJECT_NAME`` β default ``doc-redaction-langgraph`` (Phoenix also
|
| 16 |
+
accepts ``PHOENIX_PROJECT_NAME``)
|
| 17 |
+
- AX (``ARIZE_BACKEND=ax``): ``ARIZE_SPACE_ID`` / ``ARIZE_API_KEY`` required;
|
| 18 |
+
``ARIZE_ENDPOINT`` β ``europe`` (default) or ``us``
|
| 19 |
+
- Phoenix (``ARIZE_BACKEND=phoenix``): ``PHOENIX_COLLECTOR_ENDPOINT`` (default
|
| 20 |
+
``http://localhost:6006``); optional ``PHOENIX_API_KEY``
|
| 21 |
+
|
| 22 |
+
Smoke check
|
| 23 |
+
-----------
|
| 24 |
+
1. Set ``AGENT_ORCHESTRATOR=langgraph`` and tracing env vars.
|
| 25 |
+
2. For Phoenix: run ``phoenix serve``, set ``ARIZE_BACKEND=phoenix``, then
|
| 26 |
+
start the agent Gradio app and send one short chat that triggers an LLM call.
|
| 27 |
+
3. Confirm spans in Phoenix (``http://localhost:6006``) or the Arize AX project.
|
| 28 |
+
4. Send a follow-up in the same Gradio session; both turns should share one
|
| 29 |
+
Sessions row (``session.id`` = Gradio ``session_hash``).
|
| 30 |
+
5. With tracing disabled or ``AGENT_ORCHESTRATOR=pi``: confirm no errors and
|
| 31 |
+
no collector traffic.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import os
|
| 37 |
+
import warnings
|
| 38 |
+
from collections.abc import Iterator
|
| 39 |
+
from contextlib import contextmanager
|
| 40 |
+
from typing import Any
|
| 41 |
+
|
| 42 |
+
_INITIALIZED = False
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _env_truthy(name: str) -> bool:
|
| 46 |
+
return (os.environ.get(name) or "").strip().lower() in {
|
| 47 |
+
"1",
|
| 48 |
+
"true",
|
| 49 |
+
"yes",
|
| 50 |
+
"on",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _tracing_backend() -> str:
|
| 55 |
+
"""Return ``ax`` or ``phoenix`` from ``ARIZE_BACKEND`` (default ``ax``)."""
|
| 56 |
+
raw = (os.environ.get("ARIZE_BACKEND") or "ax").strip().lower()
|
| 57 |
+
if raw in {"phoenix", "local", "oss"}:
|
| 58 |
+
return "phoenix"
|
| 59 |
+
return "ax"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _project_name() -> str:
|
| 63 |
+
return (
|
| 64 |
+
os.environ.get("PHOENIX_PROJECT_NAME")
|
| 65 |
+
or os.environ.get("ARIZE_PROJECT_NAME")
|
| 66 |
+
or "doc-redaction-langgraph"
|
| 67 |
+
).strip()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _phoenix_otlp_endpoint(collector: str) -> str:
|
| 71 |
+
"""Normalize collector base URL to an HTTP OTLP traces endpoint when needed."""
|
| 72 |
+
ep = collector.strip().rstrip("/")
|
| 73 |
+
if not ep:
|
| 74 |
+
ep = "http://localhost:6006"
|
| 75 |
+
if ep.endswith("/v1/traces") or ep.endswith(":4317"):
|
| 76 |
+
return ep
|
| 77 |
+
return f"{ep}/v1/traces"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def arize_session_id(session_hash: str | None) -> str | None:
|
| 81 |
+
"""Return a non-empty session id for OpenInference, or None."""
|
| 82 |
+
sid = (session_hash or "").strip()
|
| 83 |
+
return sid or None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def langgraph_trace_config(
|
| 87 |
+
session_hash: str | None,
|
| 88 |
+
*,
|
| 89 |
+
recursion_limit: int,
|
| 90 |
+
) -> dict[str, Any]:
|
| 91 |
+
"""LangGraph ``config`` with recursion limit and Arize/LangChain session keys."""
|
| 92 |
+
config: dict[str, Any] = {"recursion_limit": recursion_limit}
|
| 93 |
+
sid = arize_session_id(session_hash)
|
| 94 |
+
if sid:
|
| 95 |
+
# LangChainInstrumentor groups turns via any of these metadata keys.
|
| 96 |
+
config["metadata"] = {
|
| 97 |
+
"session_id": sid,
|
| 98 |
+
"thread_id": sid,
|
| 99 |
+
"conversation_id": sid,
|
| 100 |
+
}
|
| 101 |
+
config["configurable"] = {"thread_id": sid}
|
| 102 |
+
return config
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@contextmanager
|
| 106 |
+
def arize_session_context(session_hash: str | None) -> Iterator[None]:
|
| 107 |
+
"""Attach OpenInference ``session.id`` for the duration of a LangGraph turn.
|
| 108 |
+
|
| 109 |
+
No-op when *session_hash* is empty or ``openinference`` is not installed.
|
| 110 |
+
Safe to use inside generators (context stays active across ``yield``).
|
| 111 |
+
"""
|
| 112 |
+
sid = arize_session_id(session_hash)
|
| 113 |
+
if not sid:
|
| 114 |
+
yield
|
| 115 |
+
return
|
| 116 |
+
try:
|
| 117 |
+
from openinference.instrumentation import using_session
|
| 118 |
+
except ImportError:
|
| 119 |
+
yield
|
| 120 |
+
return
|
| 121 |
+
with using_session(session_id=sid):
|
| 122 |
+
yield
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _setup_phoenix_tracing(project_name: str) -> bool:
|
| 126 |
+
"""Register Phoenix OTEL exporter + LangChainInstrumentor."""
|
| 127 |
+
try:
|
| 128 |
+
from openinference.instrumentation.langchain import LangChainInstrumentor
|
| 129 |
+
from phoenix.otel import register
|
| 130 |
+
except ImportError as exc:
|
| 131 |
+
warnings.warn(
|
| 132 |
+
f"Phoenix tracing dependencies unavailable ({exc}); tracing skipped. "
|
| 133 |
+
"Install arize-phoenix-otel and openinference-instrumentation-langchain.",
|
| 134 |
+
UserWarning,
|
| 135 |
+
stacklevel=3,
|
| 136 |
+
)
|
| 137 |
+
return False
|
| 138 |
+
|
| 139 |
+
collector = (
|
| 140 |
+
os.environ.get("PHOENIX_COLLECTOR_ENDPOINT") or "http://localhost:6006"
|
| 141 |
+
).strip()
|
| 142 |
+
endpoint = _phoenix_otlp_endpoint(collector)
|
| 143 |
+
register_kwargs: dict[str, Any] = {
|
| 144 |
+
"project_name": project_name,
|
| 145 |
+
"endpoint": endpoint,
|
| 146 |
+
}
|
| 147 |
+
api_key = (os.environ.get("PHOENIX_API_KEY") or "").strip()
|
| 148 |
+
if api_key:
|
| 149 |
+
register_kwargs["api_key"] = api_key
|
| 150 |
+
|
| 151 |
+
tracer_provider = register(**register_kwargs)
|
| 152 |
+
LangChainInstrumentor().instrument(tracer_provider=tracer_provider)
|
| 153 |
+
return True
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _setup_ax_tracing(project_name: str) -> bool:
|
| 157 |
+
"""Register Arize AX tracer + LangChainInstrumentor."""
|
| 158 |
+
space_id = (os.environ.get("ARIZE_SPACE_ID") or "").strip()
|
| 159 |
+
api_key = (os.environ.get("ARIZE_API_KEY") or "").strip()
|
| 160 |
+
if not space_id or not api_key:
|
| 161 |
+
warnings.warn(
|
| 162 |
+
"ARIZE_TRACING_ENABLED is set but ARIZE_SPACE_ID / ARIZE_API_KEY "
|
| 163 |
+
"are missing; Arize AX tracing is skipped.",
|
| 164 |
+
UserWarning,
|
| 165 |
+
stacklevel=3,
|
| 166 |
+
)
|
| 167 |
+
return False
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
from arize.otel import Endpoint, register
|
| 171 |
+
from openinference.instrumentation.langchain import LangChainInstrumentor
|
| 172 |
+
except ImportError as exc:
|
| 173 |
+
warnings.warn(
|
| 174 |
+
f"Arize AX tracing dependencies unavailable ({exc}); tracing skipped. "
|
| 175 |
+
"Install arize-otel and openinference-instrumentation-langchain.",
|
| 176 |
+
UserWarning,
|
| 177 |
+
stacklevel=3,
|
| 178 |
+
)
|
| 179 |
+
return False
|
| 180 |
+
|
| 181 |
+
endpoint_raw = (os.environ.get("ARIZE_ENDPOINT") or "europe").strip().lower()
|
| 182 |
+
if endpoint_raw in {"us", "arize", "default"}:
|
| 183 |
+
endpoint = Endpoint.ARIZE
|
| 184 |
+
else:
|
| 185 |
+
# Default to Europe (draft / Lambeth deployment).
|
| 186 |
+
endpoint = Endpoint.ARIZE_EUROPE
|
| 187 |
+
|
| 188 |
+
tracer_provider = register(
|
| 189 |
+
space_id=space_id,
|
| 190 |
+
api_key=api_key,
|
| 191 |
+
project_name=project_name,
|
| 192 |
+
endpoint=endpoint,
|
| 193 |
+
)
|
| 194 |
+
LangChainInstrumentor().instrument(tracer_provider=tracer_provider)
|
| 195 |
+
return True
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def setup_arize_ax_tracing() -> bool:
|
| 199 |
+
"""Register Arize AX or Phoenix tracer + LangChainInstrumentor.
|
| 200 |
+
|
| 201 |
+
Backend is selected via ``ARIZE_BACKEND`` (``ax`` default, or ``phoenix``).
|
| 202 |
+
|
| 203 |
+
Returns True if tracing was enabled (or already initialized). Soft-fails
|
| 204 |
+
with a warning when partially configured or packages are missing.
|
| 205 |
+
"""
|
| 206 |
+
global _INITIALIZED
|
| 207 |
+
if _INITIALIZED:
|
| 208 |
+
return True
|
| 209 |
+
|
| 210 |
+
if not _env_truthy("ARIZE_TRACING_ENABLED"):
|
| 211 |
+
return False
|
| 212 |
+
|
| 213 |
+
project_name = _project_name()
|
| 214 |
+
backend = _tracing_backend()
|
| 215 |
+
if backend == "phoenix":
|
| 216 |
+
ok = _setup_phoenix_tracing(project_name)
|
| 217 |
+
else:
|
| 218 |
+
ok = _setup_ax_tracing(project_name)
|
| 219 |
+
|
| 220 |
+
if ok:
|
| 221 |
+
_INITIALIZED = True
|
| 222 |
+
return ok
|
agent-redact/pi-agent/.dockerignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.github
|
| 3 |
+
**/__pycache__
|
| 4 |
+
**/*.pyc
|
| 5 |
+
**/.pytest_cache
|
| 6 |
+
**/node_modules
|
| 7 |
+
workspace
|
| 8 |
+
output
|
| 9 |
+
input
|
| 10 |
+
config/agent.env
|
| 11 |
+
config/pi_agent.env
|
agent-redact/pi-agent/.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Example PDFs must be plain files in the Space repo (not Git LFS pointers).
|
| 2 |
+
*.pdf -filter -diff -merge
|
agent-redact/pi-agent/Dockerfile
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
# Pi agent image (dev + production). Build from monorepo root:
|
| 3 |
+
# docker build -f agent-redact/pi-agent/Dockerfile --target dev .
|
| 4 |
+
# docker build -f agent-redact/pi-agent/Dockerfile --target runtime .
|
| 5 |
+
# Root .dockerignore must allow config/*.example into the context (secrets stay gitignored).
|
| 6 |
+
#
|
| 7 |
+
# Targets:
|
| 8 |
+
# dev β docker-compose: Pi CLI + Python deps; app tree bind-mounted at runtime.
|
| 9 |
+
# runtime β HF Space / AWS ECS: baked agent-redact tree, non-root user, named volumes.
|
| 10 |
+
|
| 11 |
+
# ===================================================================
|
| 12 |
+
# Stage 1: Pi CLI (Node) β isolated so the runtime base stays Python 3.12
|
| 13 |
+
# ===================================================================
|
| 14 |
+
FROM public.ecr.aws/docker/library/node:24.16.0-slim AS pi-cli
|
| 15 |
+
|
| 16 |
+
ENV NPM_CONFIG_PREFIX=/opt/pi
|
| 17 |
+
ENV PATH="/opt/pi/bin:${PATH}"
|
| 18 |
+
|
| 19 |
+
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
| 20 |
+
|
| 21 |
+
# ===================================================================
|
| 22 |
+
# Stage 2: Shared Python base (aligned with main app Dockerfile)
|
| 23 |
+
# ===================================================================
|
| 24 |
+
FROM public.ecr.aws/docker/library/python:3.12.13-slim-trixie AS pi-base
|
| 25 |
+
|
| 26 |
+
ENV NODE_ENV=production
|
| 27 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 28 |
+
ENV NPM_CONFIG_LOGLEVEL=warn
|
| 29 |
+
ENV PYTHONUNBUFFERED=1
|
| 30 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 31 |
+
ENV APP_HOME=/home/user
|
| 32 |
+
ENV AGENT_WORKDIR=/workspace/doc_redaction
|
| 33 |
+
ENV PYTHONPATH=${AGENT_WORKDIR}:${AGENT_WORKDIR}/agent-redact:${AGENT_WORKDIR}/agent-redact/shared:${AGENT_WORKDIR}/agent-redact/pi:${AGENT_WORKDIR}/agent-redact/agentcore
|
| 34 |
+
ENV GRADIO_SERVER_NAME=0.0.0.0
|
| 35 |
+
ENV MPLCONFIGDIR=/tmp/matplotlib_cache/
|
| 36 |
+
ENV XDG_CACHE_HOME=/tmp/xdg_cache/user_1000
|
| 37 |
+
ENV PATH="/opt/pi/bin:${PATH}"
|
| 38 |
+
|
| 39 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 40 |
+
bash \
|
| 41 |
+
git \
|
| 42 |
+
curl \
|
| 43 |
+
ca-certificates \
|
| 44 |
+
procps \
|
| 45 |
+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 46 |
+
|
| 47 |
+
COPY --from=pi-cli /opt/pi /opt/pi
|
| 48 |
+
COPY --from=pi-cli /usr/local/bin/node /usr/local/bin/node
|
| 49 |
+
|
| 50 |
+
COPY agent-redact/requirements_agent.txt /tmp/requirements_agent.txt
|
| 51 |
+
RUN pip install --no-cache-dir -r /tmp/requirements_agent.txt \
|
| 52 |
+
&& rm /tmp/requirements_agent.txt
|
| 53 |
+
|
| 54 |
+
# ===================================================================
|
| 55 |
+
# Stage 3: Dev β thin image for docker-compose (repo bind-mounted)
|
| 56 |
+
# ===================================================================
|
| 57 |
+
FROM pi-base AS dev
|
| 58 |
+
|
| 59 |
+
ENV HOME=${APP_HOME}
|
| 60 |
+
ENV AGENT_WORKSPACE_DIR=${APP_HOME}/app/workspace
|
| 61 |
+
ENV AGENT_UPLOAD_ROOT=/tmp/gradio
|
| 62 |
+
ENV AGENT_SESSION_DIR=${APP_HOME}/.pi/agent/sessions
|
| 63 |
+
|
| 64 |
+
RUN useradd -m -u 1000 user \
|
| 65 |
+
&& mkdir -p \
|
| 66 |
+
${APP_HOME}/app/workspace \
|
| 67 |
+
${APP_HOME}/.pi/agent/sessions \
|
| 68 |
+
${AGENT_WORKDIR} \
|
| 69 |
+
/tmp/gradio \
|
| 70 |
+
/tmp/matplotlib_cache \
|
| 71 |
+
${XDG_CACHE_HOME} \
|
| 72 |
+
&& chown -R user:user ${APP_HOME} ${AGENT_WORKDIR} \
|
| 73 |
+
&& chown user:user /tmp/gradio /tmp/matplotlib_cache ${XDG_CACHE_HOME} \
|
| 74 |
+
&& chmod 1777 /tmp/gradio /tmp/matplotlib_cache \
|
| 75 |
+
&& chmod 700 ${XDG_CACHE_HOME}
|
| 76 |
+
|
| 77 |
+
WORKDIR ${AGENT_WORKDIR}
|
| 78 |
+
|
| 79 |
+
USER user
|
| 80 |
+
|
| 81 |
+
RUN pi --version
|
| 82 |
+
|
| 83 |
+
# Compose overrides entrypoint with agent-redact/shared/start.sh on the bind mount.
|
| 84 |
+
|
| 85 |
+
# ===================================================================
|
| 86 |
+
# Stage 4: Runtime β baked app for Hugging Face Space and AWS ECS
|
| 87 |
+
# ===================================================================
|
| 88 |
+
FROM pi-base AS runtime
|
| 89 |
+
|
| 90 |
+
ENV AGENT_DEPLOYMENT_PROFILE=hf-space
|
| 91 |
+
ENV AGENT_DEFAULT_PROVIDER=google-gemini
|
| 92 |
+
ENV AGENT_DEFAULT_MODEL=gemini-flash-lite-latest
|
| 93 |
+
ENV DOC_REDACTION_GRADIO_URL=https://seanpedrickcase-document-redaction.hf.space
|
| 94 |
+
ENV HOME=${APP_HOME}
|
| 95 |
+
ENV AGENT_WORKDIR=/workspace/doc_redaction
|
| 96 |
+
# Fargate uses volume mounts under ${APP_HOME}/app/workspace (CDK chown entrypoint).
|
| 97 |
+
# ECS Express has no mounts β CDK sets AGENT_WORKSPACE_DIR=/tmp/agent-workspace at deploy.
|
| 98 |
+
ENV AGENT_WORKSPACE_DIR=${APP_HOME}/app/workspace
|
| 99 |
+
ENV AGENT_UPLOAD_ROOT=/tmp/gradio
|
| 100 |
+
ENV AGENT_SESSION_DIR=/tmp/agent-sessions
|
| 101 |
+
ENV AGENT_CODING_AGENT_DIR=/tmp/agent-coding
|
| 102 |
+
ENV PI_CODING_AGENT_DIR=/tmp/agent-coding
|
| 103 |
+
ENV PI_CODING_AGENT_SESSION_DIR=/tmp/agent-sessions
|
| 104 |
+
ENV ACCESS_LOGS_FOLDER=/tmp/agent-logs/
|
| 105 |
+
ENV USAGE_LOGS_FOLDER=/tmp/agent-usage/
|
| 106 |
+
ENV FEEDBACK_LOGS_FOLDER=/tmp/agent-feedback/
|
| 107 |
+
ENV PI_OFFLINE=1
|
| 108 |
+
ENV PI_SKIP_VERSION_CHECK=1
|
| 109 |
+
ENV AGENT_GRADIO_SHOW_EXAMPLES=true
|
| 110 |
+
ENV AGENT_UI_HOST=0.0.0.0
|
| 111 |
+
ENV AGENT_UI_PORT=7860
|
| 112 |
+
ENV AGENT_GRADIO_PORT=7860
|
| 113 |
+
ENV GRADIO_SERVER_NAME=0.0.0.0
|
| 114 |
+
ENV GRADIO_SERVER_PORT=7860
|
| 115 |
+
ENV GRADIO_ANALYTICS_ENABLED=False
|
| 116 |
+
ENV RUN_FASTAPI=False
|
| 117 |
+
|
| 118 |
+
WORKDIR ${AGENT_WORKDIR}
|
| 119 |
+
|
| 120 |
+
COPY agent-redact/shared agent-redact/shared
|
| 121 |
+
COPY agent-redact/pi agent-redact/pi
|
| 122 |
+
COPY skills skills
|
| 123 |
+
COPY tools tools
|
| 124 |
+
# Committed template only (see sync-manifest.txt); runtime secrets come from S3/env on ECS.
|
| 125 |
+
COPY config/agent.env.example config/agent.env.example
|
| 126 |
+
COPY intros intros
|
| 127 |
+
COPY AGENTS.md AGENTS.md
|
| 128 |
+
COPY doc_redaction/example_data doc_redaction/example_data
|
| 129 |
+
|
| 130 |
+
RUN test -f doc_redaction/example_data/example_of_emails_sent_to_a_professor_before_applying.pdf \
|
| 131 |
+
&& test -f doc_redaction/example_data/graduate-job-example-cover-letter.pdf \
|
| 132 |
+
&& ! head -1 doc_redaction/example_data/example_of_emails_sent_to_a_professor_before_applying.pdf \
|
| 133 |
+
| grep -q "^version https://git-lfs.github.com/spec/v1"
|
| 134 |
+
|
| 135 |
+
RUN useradd -m -u 1000 user \
|
| 136 |
+
&& mkdir -p \
|
| 137 |
+
${APP_HOME}/app/workspace \
|
| 138 |
+
${APP_HOME}/.pi/agent \
|
| 139 |
+
/tmp/gradio \
|
| 140 |
+
/tmp/agent-sessions \
|
| 141 |
+
/tmp/matplotlib_cache \
|
| 142 |
+
${XDG_CACHE_HOME} \
|
| 143 |
+
&& chown user:user \
|
| 144 |
+
${APP_HOME}/app/workspace \
|
| 145 |
+
${APP_HOME}/.pi \
|
| 146 |
+
/tmp/gradio \
|
| 147 |
+
/tmp/agent-sessions \
|
| 148 |
+
/tmp/matplotlib_cache \
|
| 149 |
+
${XDG_CACHE_HOME} \
|
| 150 |
+
&& chmod 755 ${APP_HOME}/app/workspace ${APP_HOME}/.pi \
|
| 151 |
+
&& chmod 1777 /tmp/gradio /tmp/agent-sessions /tmp/matplotlib_cache \
|
| 152 |
+
&& chmod 700 ${XDG_CACHE_HOME} \
|
| 153 |
+
&& chown -R root:root ${AGENT_WORKDIR} \
|
| 154 |
+
&& find ${AGENT_WORKDIR} -type d -exec chmod 755 {} \; \
|
| 155 |
+
&& find ${AGENT_WORKDIR} -type f -exec chmod 644 {} \; \
|
| 156 |
+
&& mkdir -p ${APP_HOME}/app \
|
| 157 |
+
&& chown user:user ${APP_HOME}/app
|
| 158 |
+
|
| 159 |
+
COPY agent-redact/pi-agent/entrypoint-ecs.sh /usr/local/bin/entrypoint-ecs.sh
|
| 160 |
+
COPY agent-redact/pi-agent/entrypoint.sh ${APP_HOME}/app/entrypoint.sh
|
| 161 |
+
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint-ecs.sh ${APP_HOME}/app/entrypoint.sh \
|
| 162 |
+
&& chmod +x /usr/local/bin/entrypoint-ecs.sh ${APP_HOME}/app/entrypoint.sh
|
| 163 |
+
|
| 164 |
+
# Writable paths only via runtime mounts (read-only root FS friendly).
|
| 165 |
+
VOLUME ["${APP_HOME}/app/workspace"]
|
| 166 |
+
VOLUME ["/tmp/gradio"]
|
| 167 |
+
VOLUME ["/tmp/agent-sessions"]
|
| 168 |
+
VOLUME ["/tmp/matplotlib_cache"]
|
| 169 |
+
VOLUME ["${XDG_CACHE_HOME}"]
|
| 170 |
+
VOLUME ["/tmp"]
|
| 171 |
+
VOLUME ["/var/tmp"]
|
| 172 |
+
|
| 173 |
+
USER user
|
| 174 |
+
|
| 175 |
+
RUN pi --version
|
| 176 |
+
|
| 177 |
+
EXPOSE 7860
|
| 178 |
+
|
| 179 |
+
ENTRYPOINT ["/home/user/app/entrypoint.sh"]
|
agent-redact/pi-agent/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Agentic Document Redaction
|
| 3 |
+
emoji: π€
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_file: agent-redact/shared/gradio_app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
license: agpl-3.0
|
| 10 |
+
short_description: Agentic interface to redact PDF documents
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Pi agent β agentic document redaction
|
| 14 |
+
|
| 15 |
+
Orchestrate document redaction with **[Pi](https://github.com/earendil-works/pi)** and **Google Gemini**. Heavy redaction runs on a separate **private [doc_redaction](https://huggingface.co/spaces/seanpedrickcase/document_redaction)** Hugging Face Space (simple text extraction + Local PII).
|
| 16 |
+
|
| 17 |
+
## Before you start
|
| 18 |
+
|
| 19 |
+
1. **Gemini API key** β paste in **Agent backend** β **Apply backend** (session-only; not stored on disk).
|
| 20 |
+
2. **HF token** β Space admin should set `HF_TOKEN` under **Settings β Secrets** so this Space can call the private redaction backend. Users may optionally override per session in the UI.
|
| 21 |
+
|
| 22 |
+
## Limitations
|
| 23 |
+
|
| 24 |
+
- **No face or signature VLM** β text-layer PII only via Local spaCy/Presidio on the remote Space.
|
| 25 |
+
- **No Pass 2 VLM** on this deployment.
|
| 26 |
+
- **Ephemeral storage** β download deliverables from **Workspace output files** before the Space restarts.
|
| 27 |
+
- **Human review** β outputs are not guaranteed complete; review redacted PDFs before release.
|
| 28 |
+
|
| 29 |
+
## Defaults
|
| 30 |
+
|
| 31 |
+
| Setting | Value |
|
| 32 |
+
|---------|--------|
|
| 33 |
+
| Pi LLM | Gemini (`gemini-flash-latest` default) |
|
| 34 |
+
| Redaction backend | `https://seanpedrickcase-document-redaction.hf.space` |
|
| 35 |
+
| Text extraction | `Local model - selectable text` |
|
| 36 |
+
| PII detection | `Local` |
|
| 37 |
+
|
| 38 |
+
## Examples
|
| 39 |
+
|
| 40 |
+
Two sample PDFs load in **Redaction task** β **Try an example** (same demos as the main doc_redaction app). Examples are **on by default**; set Space variable `AGENT_GRADIO_SHOW_EXAMPLES=false` to hide them. (`SHOW_PI_EXAMPLES` is also accepted.)
|
| 41 |
+
|
| 42 |
+
If examples do not appear, the UI shows a short status message (usually missing PDFs in the image β rebuild after a successful sync with LFS materialization).
|
| 43 |
+
|
| 44 |
+
## Development
|
| 45 |
+
|
| 46 |
+
This Space is synced from the [doc_redaction monorepo](https://github.com/seanpedrick-case/doc_redaction) on pushes to **`dev`** (see `.github/workflows/sync-pi-agent-space.yml`). Space: [seanpedrickcase/agentic_document_redaction](https://huggingface.co/spaces/seanpedrickcase/agentic_document_redaction).
|
agent-redact/pi-agent/entrypoint-ecs.sh
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# ECS Fargate: ephemeral volume mounts are root-owned; chown then drop to user (image USER).
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
|
| 5 |
+
for dir in /tmp/agent-coding /tmp/agent-logs /tmp/agent-usage /tmp/agent-feedback \
|
| 6 |
+
/home/user/app/workspace /tmp/gradio /tmp/agent-sessions; do
|
| 7 |
+
mkdir -p "$dir"
|
| 8 |
+
chown -R user:user "$dir"
|
| 9 |
+
done
|
| 10 |
+
|
| 11 |
+
cd /workspace/doc_redaction
|
| 12 |
+
exec su -s /bin/bash user -c "$*"
|
agent-redact/pi-agent/entrypoint.sh
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
echo "Starting Pi agent (profile=${AGENT_DEPLOYMENT_PROFILE:-unknown})"
|
| 5 |
+
|
| 6 |
+
for dir in \
|
| 7 |
+
"${AGENT_CODING_AGENT_DIR:-/tmp/agent-coding}" \
|
| 8 |
+
"${AGENT_WORKSPACE_DIR:-/home/user/app/workspace}" \
|
| 9 |
+
"${AGENT_UPLOAD_ROOT:-/tmp/gradio}" \
|
| 10 |
+
"${AGENT_SESSION_DIR:-/tmp/agent-sessions}" \
|
| 11 |
+
"${ACCESS_LOGS_FOLDER:-/tmp/agent-logs}" \
|
| 12 |
+
"${USAGE_LOGS_FOLDER:-/tmp/agent-usage}" \
|
| 13 |
+
"${FEEDBACK_LOGS_FOLDER:-/tmp/agent-feedback}" \
|
| 14 |
+
"${MPLCONFIGDIR:-/tmp/matplotlib_cache}" \
|
| 15 |
+
"${XDG_CACHE_HOME:-/tmp/xdg_cache/user_1000}"; do
|
| 16 |
+
mkdir -p "$dir" 2>/dev/null || true
|
| 17 |
+
if [ ! -w "$dir" ]; then
|
| 18 |
+
echo "WARNING: Directory $dir is not writable by current user (uid=$(id -u)). File I/O may fail." >&2
|
| 19 |
+
fi
|
| 20 |
+
done
|
| 21 |
+
|
| 22 |
+
cd "${AGENT_WORKDIR:-/workspace/doc_redaction}"
|
| 23 |
+
|
| 24 |
+
echo "Entrypoint environment: AGENT_WORKSPACE_DIR=${AGENT_WORKSPACE_DIR:-} AGENT_UI_HOST=${AGENT_UI_HOST:-} AGENT_UI_PORT=${AGENT_UI_PORT:-} AGENT_GRADIO_PORT=${AGENT_GRADIO_PORT:-} GRADIO_SERVER_NAME=${GRADIO_SERVER_NAME:-} GRADIO_SERVER_PORT=${GRADIO_SERVER_PORT:-} RUN_FASTAPI=${RUN_FASTAPI:-}"
|
| 25 |
+
|
| 26 |
+
python3 agent-redact/pi/pi_agent_config.py
|
| 27 |
+
if [ "${RUN_FASTAPI:-False}" = "True" ]; then
|
| 28 |
+
exec uvicorn gradio_app:app \
|
| 29 |
+
--app-dir agent-redact/shared \
|
| 30 |
+
--host "${GRADIO_SERVER_NAME:-0.0.0.0}" \
|
| 31 |
+
--port "${AGENT_GRADIO_PORT:-${GRADIO_SERVER_PORT:-7860}}" \
|
| 32 |
+
--proxy-headers \
|
| 33 |
+
--forwarded-allow-ips "*"
|
| 34 |
+
else
|
| 35 |
+
exec python3 agent-redact/shared/gradio_app.py
|
| 36 |
+
fi
|
agent-redact/pi-agent/sync-manifest.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Paths copied from the monorepo root into the flattened Pi agent HF Space repo.
|
| 2 |
+
agent-redact/requirements_agent.txt
|
| 3 |
+
agent-redact/shared
|
| 4 |
+
agent-redact/pi
|
| 5 |
+
agent-redact/pi-agent/entrypoint.sh
|
| 6 |
+
agent-redact/pi-agent/entrypoint-ecs.sh
|
| 7 |
+
skills
|
| 8 |
+
tools
|
| 9 |
+
config/agent.env.example
|
| 10 |
+
intros/pi_intro.txt
|
| 11 |
+
AGENTS.md
|
| 12 |
+
doc_redaction/example_data/example_of_emails_sent_to_a_professor_before_applying.pdf
|
| 13 |
+
doc_redaction/example_data/graduate-job-example-cover-letter.pdf
|
agent-redact/pi-agent/sync_to_space.sh
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Flatten monorepo paths into a temp directory for the Pi agent HF Space repo.
|
| 3 |
+
# Usage (from repo root):
|
| 4 |
+
# agent-redact/pi-agent/sync_to_space.sh /path/to/output-dir
|
| 5 |
+
set -euo pipefail
|
| 6 |
+
|
| 7 |
+
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
| 8 |
+
OUT="${1:?Output directory required}"
|
| 9 |
+
MANIFEST="$(dirname "$0")/sync-manifest.txt"
|
| 10 |
+
|
| 11 |
+
_is_lfs_pointer() {
|
| 12 |
+
[[ -f "$1" ]] && head -1 "$1" 2>/dev/null | grep -q "^version https://git-lfs.github.com/spec/v1"
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
rm -rf "$OUT"
|
| 16 |
+
mkdir -p "$OUT"
|
| 17 |
+
|
| 18 |
+
cp "$(dirname "$0")/Dockerfile" "$OUT/Dockerfile"
|
| 19 |
+
cp "$(dirname "$0")/README.md" "$OUT/README.md"
|
| 20 |
+
cp "$(dirname "$0")/.dockerignore" "$OUT/.dockerignore"
|
| 21 |
+
cp "$(dirname "$0")/.gitattributes" "$OUT/.gitattributes"
|
| 22 |
+
|
| 23 |
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
| 24 |
+
line="${line%%#*}"
|
| 25 |
+
line="$(echo "$line" | xargs)"
|
| 26 |
+
[[ -z "$line" ]] && continue
|
| 27 |
+
src="$ROOT/$line"
|
| 28 |
+
if [[ ! -e "$src" ]]; then
|
| 29 |
+
echo "Missing: $src" >&2
|
| 30 |
+
exit 1
|
| 31 |
+
fi
|
| 32 |
+
dest="$OUT/$line"
|
| 33 |
+
mkdir -p "$(dirname "$dest")"
|
| 34 |
+
cp -a "$src" "$dest"
|
| 35 |
+
if [[ "$line" == *.pdf ]] && _is_lfs_pointer "$dest"; then
|
| 36 |
+
echo "Copied file is a Git LFS pointer, not a PDF: $line" >&2
|
| 37 |
+
echo "Run 'git lfs pull' in the monorepo before syncing." >&2
|
| 38 |
+
exit 1
|
| 39 |
+
fi
|
| 40 |
+
done < "$MANIFEST"
|
| 41 |
+
|
| 42 |
+
echo "Flattened Pi agent Space tree: $OUT"
|
agent-redact/pi/agent/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pi agent config (Docker)
|
| 2 |
+
|
| 3 |
+
Runtime Pi config is **generated at container start** by [`agent-redact/pi/pi_agent_config.py`](../pi_agent_config.py) into `~/.pi/agent/models.json` and `~/.pi/agent/settings.json`.
|
| 4 |
+
|
| 5 |
+
Files in this folder (`settings.json`, `models.json`) are **templates/references** only β they are no longer bind-mounted into the container.
|
| 6 |
+
|
| 7 |
+
## LLM backends (Pi orchestration)
|
| 8 |
+
|
| 9 |
+
The Pi agent (chat + redaction orchestration) can use:
|
| 10 |
+
|
| 11 |
+
| Provider key | Label | Pi API | Auth |
|
| 12 |
+
|--------------|-------|--------|------|
|
| 13 |
+
| `llama-cpp` | Local (llama-cpp) | `openai-completions` | None (local llama-inference) |
|
| 14 |
+
| `google-gemini` | Gemini | `google-generative-ai` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
|
| 15 |
+
| `amazon-bedrock` | AWS Bedrock | `bedrock-converse-stream` | AWS SDK credentials (`AWS_ACCESS_KEY_ID`, etc.) |
|
| 16 |
+
|
| 17 |
+
This is separate from doc_redaction **Pass 2 VLM** (`{VLM_BASE_URL}` in redaction prompts), which still targets local llama-inference by default.
|
| 18 |
+
|
| 19 |
+
## Orchestration backends (`AGENT_ORCHESTRATOR`)
|
| 20 |
+
|
| 21 |
+
The Gradio UI can drive four orchestration backends (see [`agent_runtime.py`](../agent_runtime.py)):
|
| 22 |
+
|
| 23 |
+
| Value | Runtime | Notes |
|
| 24 |
+
|-------|---------|-------|
|
| 25 |
+
| `pi` (default) | Pi coding agent (`pi --mode rpc`) | Full bash + skills; retained for HF Space and gradual migration |
|
| 26 |
+
| `langgraph` | LangGraph ReAct agent | Curated Python tools only (no shell); local llama.cpp / Bedrock / Gemini |
|
| 27 |
+
| `agentcore` | Bedrock AgentCore **Runtime** | LangGraph bundle via `AGENTCORE_RUNTIME_URL`; see **[AgentCore install guide](../../agentcore/README.md)** |
|
| 28 |
+
| `agentcore-harness` (alias `harness`) | Bedrock AgentCore **Harness** | `InvokeHarness` via `AGENTCORE_HARNESS_ARN`; Pi-like partnership prompt; S3 file bridge |
|
| 29 |
+
|
| 30 |
+
Set in `config/agent.env` or compose:
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
AGENT_ORCHESTRATOR=langgraph
|
| 34 |
+
# AGENT_ORCHESTRATOR=agentcore
|
| 35 |
+
# AGENTCORE_RUNTIME_URL=https://...
|
| 36 |
+
# AGENT_ORCHESTRATOR=agentcore-harness
|
| 37 |
+
# AGENTCORE_HARNESS_ARN=arn:aws:bedrock-agentcore:...
|
| 38 |
+
# AGENTCORE_HARNESS_S3_INPUT_PREFIX=s3://bucket/harness-inputs/
|
| 39 |
+
# LANGGRAPH_REQUIRE_REVIEW_APPROVAL=true # gate review_apply until approve_review_apply tool
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Headless LangGraph spike: `python agent-redact/redaction_langgraph/headless_pass1.py --pdf path/to.pdf --direct-tool`
|
| 43 |
+
|
| 44 |
+
### Environment variables
|
| 45 |
+
|
| 46 |
+
Copy [`config/agent.env.example`](../../../config/agent.env.example) to `config/agent.env` (gitignored) or set on the host before `docker compose up`:
|
| 47 |
+
|
| 48 |
+
| Variable | Purpose |
|
| 49 |
+
|----------|---------|
|
| 50 |
+
| `AGENT_DEFAULT_PROVIDER` | `llama-cpp` \| `google-gemini` \| `amazon-bedrock` |
|
| 51 |
+
| `AGENT_DEFAULT_MODEL` | Model id within provider |
|
| 52 |
+
| `AGENT_LLAMA_BASE_URL` | Local OpenAI-compatible URL (default `http://llama-inference:8080/v1`) |
|
| 53 |
+
| `AGENT_LLAMA_MODEL_ID` | Local model id |
|
| 54 |
+
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Gemini API key |
|
| 55 |
+
| `AWS_REGION` / `AWS_DEFAULT_REGION` | Bedrock region |
|
| 56 |
+
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` | Bedrock credentials (when not using SSO) |
|
| 57 |
+
| `AWS_PROFILE` | Named profile for SSO / shared credentials file (**required for Pi Bedrock with SSO**) |
|
| 58 |
+
| `AGENT_AWS_PROFILE` | Alternative to `AWS_PROFILE`; also used to auto-select profile when only `~/.aws` is mounted |
|
| 59 |
+
| `RUN_AWS_FUNCTIONS` | When `True`, use the AWS default credential chain (SSO, profile, role) |
|
| 60 |
+
| `PRIORITISE_SSO_OVER_AWS_ENV_ACCESS_KEYS` | When `True` with `RUN_AWS_FUNCTIONS`, prefer SSO/chain over static env keys (default `True`, same as main app) |
|
| 61 |
+
| `AGENT_MAX_PAGES` | Maximum PDF pages allowed per redaction upload (falls back to `MAX_PAGES` / `MAX_DOC_PAGES`, default `3000`) |
|
| 62 |
+
| `AGENT_MAX_RETRIES` | Gemini quota / rate-limit retries for Pi auto-retry and Gradio backoff (default `5`; alias `AGENT_QUOTA_RETRY_ATTEMPTS`) |
|
| 63 |
+
| `AGENT_QUOTA_RETRY_DELAY_S` | Seconds between Gradio quota retries (default `60`) |
|
| 64 |
+
| `AGENT_COMPACTION_ENABLED` | Pi session auto-compaction in `settings.json` (`true` / `false`; unset uses template default, enabled) |
|
| 65 |
+
| `AGENT_COMPACTION_RESERVE_TOKENS` | Optional compaction `reserveTokens` (default `32768` from template) |
|
| 66 |
+
| `AGENT_COMPACTION_KEEP_RECENT_TOKENS` | Optional compaction `keepRecentTokens` (default `20000` from template) |
|
| 67 |
+
| `AGENT_CODING_AGENT_DIR` | Writable directory for generated `models.json` / `settings.json` (HF/ECS: `/tmp/agent-coding`) |
|
| 68 |
+
| `AGENT_SESSION_DIR` | Pi session JSONL directory (HF/ECS: `/tmp/agent-sessions`) |
|
| 69 |
+
| `PI_CODING_AGENT_DIR` | Pi CLI config directory (auto-mirrored from `AGENT_CODING_AGENT_DIR` at startup) |
|
| 70 |
+
| `PI_CODING_AGENT_SESSION_DIR` | Pi CLI session directory (auto-mirrored from `AGENT_SESSION_DIR`; overrides `settings.json`) |
|
| 71 |
+
|
| 72 |
+
### Usage logging (CSV / DynamoDB / S3)
|
| 73 |
+
|
| 74 |
+
Each completed Pi agent run (chat message or redaction task) writes **one row** to the **same usage log schema** as the main redaction app (`USAGE_LOG_FILE_NAME`, `USAGE_LOGS_FOLDER`, `S3_USAGE_LOGS_FOLDER`, `USAGE_LOG_DYNAMODB_TABLE_NAME`). Key fields:
|
| 75 |
+
|
| 76 |
+
| Log column | Pi agent value |
|
| 77 |
+
|------------|----------------|
|
| 78 |
+
| `task` | `agent` |
|
| 79 |
+
| `llm_model_name` | Pi provider/model (e.g. `amazon-bedrock/anthropic.claude-sonnet-4-6`) |
|
| 80 |
+
| `text_extraction_method` / `pii_detection_method` | From redaction task settings when applicable |
|
| 81 |
+
| `actual_time_taken_number` | Wall-clock seconds for the Pi RPC turn |
|
| 82 |
+
| `total_page_count` | Pages in scope for PDF redaction tasks |
|
| 83 |
+
| `llm_total_input_tokens` / `llm_total_output_tokens` | Pi orchestration LLM usage for that turn (from Pi `get_session_stats` delta, or assistant `usage` in session JSONL). Includes cache read/write in the input column. **VLM/tokens from doc_redaction Pass 1 are not included** (those stay on the main app usage log when you run redaction there directly). |
|
| 84 |
+
|
| 85 |
+
Toggle with `SAVE_LOGS_TO_CSV`, `SAVE_LOGS_TO_DYNAMODB`, and `RUN_AWS_FUNCTIONS` (required for S3 log upload). Access logs on session load use the main app access log paths separately.
|
| 86 |
+
|
| 87 |
+
At startup, if only `GOOGLE_API_KEY` is set, it is mirrored to `GEMINI_API_KEY` for Pi.
|
| 88 |
+
|
| 89 |
+
### Gradio UI
|
| 90 |
+
|
| 91 |
+
Open **http://localhost:7862** β **Agent backend** accordion:
|
| 92 |
+
|
| 93 |
+
- Select provider and model
|
| 94 |
+
- Optionally enter Gemini / AWS credentials (**session-only** β not written to disk)
|
| 95 |
+
- Click **Apply backend** β regenerates config, restarts the Pi RPC subprocess, and starts a new session
|
| 96 |
+
|
| 97 |
+
Credential fields are cleared after apply.
|
| 98 |
+
|
| 99 |
+
## Local model id
|
| 100 |
+
|
| 101 |
+
After the llama.cpp service is healthy, confirm the model id:
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
curl http://localhost:8000/v1/models
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
If the returned `id` differs from `unsloth/Qwen3.6-27B-MTP-GGUF`, set `AGENT_LLAMA_MODEL_ID` in `config/agent.env` or compose environment and restart `pi-agent`.
|
| 108 |
+
|
| 109 |
+
### llama.cpp / llama-swap and back-to-back redaction tasks
|
| 110 |
+
|
| 111 |
+
If the **first** redaction task succeeds but a **second** task in the same browser session kills the llama server (`Killed`, `saving prompt with length 69804`, `proxy error: EOF`, `502`):
|
| 112 |
+
|
| 113 |
+
1. **Oversized Pi session** β the orchestration agent kept the full first run (tool logs, bash output) in context (~70k tokens). The Gradio UI **restarts the Pi RPC process** and **clears the chat panel** on **page reload** and before each **Start redaction task** (same behaviour). Workspace files are unchanged. Use **New session** before a follow-up **chat** turn if you still hit context limits.
|
| 114 |
+
2. **llama.cpp OOM** β a second task that reuses the first runβs context can try to allocate multiβGiB KV state (`total state size = 3322 MiB` in logs) and be killed by the OS. A clean Pi process keeps the orchestration prompt small.
|
| 115 |
+
3. **llama-swap GPU monitor** β on newer NVIDIA drivers, older llama-swap builds fail `nvidia-smi -loop` and can log `failed reading from gpuCh`. Upgrade to [llama-swap v213+](https://github.com/mostlygeek/llama-swap) (or disable performance monitoring in your swap config).
|
| 116 |
+
4. **Concurrent load** β Pi orchestration and doc_redaction VLM may share one llama endpoint; `--parallel 1` allows only one generation. Wait until the first task shows **Agent finished** before starting another.
|
| 117 |
+
|
| 118 |
+
For Gemma 4 31B, `pi-agent-gemma-31b` sets lower compaction defaults (`AGENT_COMPACTION_RESERVE_TOKENS=16384`) to match `AGENT_LLAMA_CONTEXT_WINDOW=65536`.
|
| 119 |
+
|
| 120 |
+
## In-container URLs for task prompts
|
| 121 |
+
|
| 122 |
+
When filling [`skills/doc-redaction-task-prompt/TASK_PROMPT_TEMPLATE.md`](../../../skills/doc-redaction-task-prompt/TASK_PROMPT_TEMPLATE.md) inside the Pi container, use:
|
| 123 |
+
|
| 124 |
+
| Placeholder | In-container value |
|
| 125 |
+
|-------------|-------------------|
|
| 126 |
+
| `{GRADIO_URL}` | `http://redaction-app-llama:7860` |
|
| 127 |
+
| `{VLM_BASE_URL}` | `http://llama-inference:8080` |
|
| 128 |
+
| `{INPUT_PATH}` | `/home/user/app/workspace/{session_hash}/{FILE_NAME}` (when `AGENT_SESSION_WORKSPACE=true`) |
|
| 129 |
+
| `{OUTPUT_BASE}` | `/home/user/app/workspace/{session_hash}/redact/{FILE_NAME}/` |
|
| 130 |
+
|
| 131 |
+
Host-side examples (`host.docker.internal`, `localhost:7861`) do not apply inside the compose network.
|
| 132 |
+
|
| 133 |
+
## Usage
|
| 134 |
+
|
| 135 |
+
Start the stack (27B profile):
|
| 136 |
+
|
| 137 |
+
```powershell
|
| 138 |
+
docker compose -f docker-compose_llama_agentic.yml --profile 27b_36 up -d --build
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
Interactive Pi TUI:
|
| 142 |
+
|
| 143 |
+
```powershell
|
| 144 |
+
docker compose -f docker-compose_llama_agentic.yml exec -it pi-agent pi
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
Gradio chat UI (browser):
|
| 148 |
+
|
| 149 |
+
Open **http://localhost:7862**. Use the **Redaction task** panel to upload a document, enter bullet-point requirements, and click **Start redaction task**. Pi receives the filled prompt from [`skills/Example prompt partnership.txt`](../../../skills/Example%20prompt%20partnership.txt) (file copied to `/home/user/app/workspace/`). The full prompt appears in the chat; Piβs reply streams in the chat panel.
|
| 150 |
+
|
| 151 |
+
The UI also shows:
|
| 152 |
+
|
| 153 |
+
- **Agent backend** β switch between local, Gemini, and Bedrock
|
| 154 |
+
- **Chat** β streamed assistant text
|
| 155 |
+
- **Activity** β agent/turn lifecycle, compaction, auto-retry, tool start/end
|
| 156 |
+
- **Tool output** β live bash/read output from `tool_execution_update` / `tool_execution_end`
|
| 157 |
+
- **Thinking** β optional stream (`AGENT_GRADIO_SHOW_THINKING=true`)
|
| 158 |
+
- **Abort** β sends Pi RPC `abort` and cancels the in-flight Gradio handler
|
| 159 |
+
- **Workspace output files** β browse and download redaction artifacts
|
| 160 |
+
|
| 161 |
+
Optional env vars on `pi-agent`: `AGENT_GRADIO_SHOW_THINKING`, `AGENT_GRADIO_SHOW_TOOL_OUTPUT`, `AGENT_GRADIO_TOOL_OUTPUT_MAX`, `AGENT_GRADIO_ACTIVITY_MAX_LINES`.
|
| 162 |
+
|
| 163 |
+
When a Pi run completes, the chat shows an **Agent finished** (or **Agent stopped**) line, a Gradio info toast appears, and the browser tab title flashes for ~15 seconds. Desktop notifications are shown when the browser has granted notification permission (requested on first click/keypress in the Pi UI).
|
| 164 |
+
|
| 165 |
+
Run the UI locally (outside Docker):
|
| 166 |
+
|
| 167 |
+
```powershell
|
| 168 |
+
cd agent-redact/shared
|
| 169 |
+
pip install -r ../requirements_agent.txt
|
| 170 |
+
# Pi orchestration subprocess (required for Apply backend / chat):
|
| 171 |
+
npm install -g @earendil-works/pi-coding-agent
|
| 172 |
+
python ../pi/pi_agent_config.py
|
| 173 |
+
python gradio_app.py
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
**Apply backend** starts `pi --mode rpc`. If you see `FileNotFoundError` / βPi CLI not foundβ, install Node.js, run the `npm install` line above, and ensure `pi` (or `pi.cmd` on Windows) is on `PATH`. Optional: `AGENT_EXECUTABLE=C:\Users\you\AppData\Roaming\npm\pi.cmd` in `config/agent.env`.
|
| 177 |
+
|
| 178 |
+
RPC mode (automation, no Gradio):
|
| 179 |
+
|
| 180 |
+
```powershell
|
| 181 |
+
docker compose -f docker-compose_llama_agentic.yml exec -T pi-agent pi --mode rpc
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
Skills are synced from the repo `skills/` tree into **`{AGENT_WORKSPACE_DIR}/.pi/skills/`** on startup (read-only). Pi runs with `cwd` in the userβs session subfolder and `--no-skills` so it does not load skills from the git checkout. Use `/skill:doc-redaction-app` etc. Set `AGENT_SKILLS_RESYNC=true` to refresh copies from the repo.
|
| 185 |
+
|
| 186 |
+
Sessions persist in the **`pi-agent-sessions`** Docker volume at **`~/.pi/agent/sessions/`** (Piβs default session location inside the container). Override with `AGENT_SESSION_DIR` if needed.
|
| 187 |
+
|
| 188 |
+
On **HF Space** (`AGENT_DEPLOYMENT_PROFILE=hf-space`), sessions go to **`/tmp/agent-sessions`** instead (ephemeral; lost on restart).
|
| 189 |
+
|
| 190 |
+
## Python dependencies
|
| 191 |
+
|
| 192 |
+
The agent image installs [`requirements_agent.txt`](../requirements_agent.txt) β Gradio UI + `gradio-client`, HTTP clients, CSV/PDF review helpers (`pandas`, `pymupdf`), and common utilities. It **does not** include spaCy, Presidio, or OCR; heavy redaction runs in `redaction-app-llama`.
|
| 193 |
+
|
| 194 |
+
Rebuild after changing that file:
|
| 195 |
+
|
| 196 |
+
```powershell
|
| 197 |
+
docker compose -f docker-compose_llama_agentic.yml --profile 27b_36 build pi-agent
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
## HF Space profile (remote redaction backend)
|
| 201 |
+
|
| 202 |
+
Set `AGENT_DEPLOYMENT_PROFILE=hf-space` to run the Pi Gradio UI as a **Hugging Face Docker Space** that orchestrates with **Gemini only** and calls a **remote** doc_redaction Space over HTTPS.
|
| 203 |
+
|
| 204 |
+
| Area | HF Space value |
|
| 205 |
+
|------|----------------|
|
| 206 |
+
| Pi LLM | Gemini only (`AGENT_DEFAULT_PROVIDER=google-gemini`) |
|
| 207 |
+
| Redaction app | `DOC_REDACTION_GRADIO_URL` (default `https://seanpedrickcase-document-redaction.hf.space`) |
|
| 208 |
+
| Auth to redaction | `HF_TOKEN` / `DOC_REDACTION_HF_TOKEN` (Space secret + optional UI override) |
|
| 209 |
+
| Text extraction / PII | Locked to `Local model - selectable text` + `Local` |
|
| 210 |
+
| VLM faces / signatures | Disabled |
|
| 211 |
+
| Port | `7860` |
|
| 212 |
+
| Pi session logs | `/tmp/agent-sessions` (`AGENT_SESSION_DIR`; ephemeral) |
|
| 213 |
+
|
| 214 |
+
Package and Dockerfile: [`agent-redact/pi-agent/`](../../pi-agent/). Pushes to [agentic_document_redaction](https://huggingface.co/spaces/seanpedrickcase/agentic_document_redaction) on **`dev`** branch via [`.github/workflows/sync-pi-agent-space.yml`](../../../.github/workflows/sync-pi-agent-space.yml) (GitHub secrets: `HF_TOKEN`, `HF_USERNAME`, `HF_EMAIL`).
|
| 215 |
+
|
| 216 |
+
Local build test from monorepo root:
|
| 217 |
+
|
| 218 |
+
```powershell
|
| 219 |
+
docker build -f agent-redact/pi-agent/Dockerfile --target runtime -t pi-agent-hf-space .
|
| 220 |
+
docker run --rm -p 7860:7860 -e GEMINI_API_KEY=... -e HF_TOKEN=... pi-agent-hf-space
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
Pi uses `gradio_client` + `agent-redact/shared/remote_redaction.py` to upload/download from the remote Space; prompts include `{REMOTE_BACKEND_GUIDANCE}` (see [`redaction_prompt.py`](../../shared/redaction_prompt.py)).
|
agent-redact/pi/agent/models.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"providers": {
|
| 3 |
+
"llama-cpp": {
|
| 4 |
+
"baseUrl": "http://llama-inference:8080/v1",
|
| 5 |
+
"api": "openai-completions",
|
| 6 |
+
"apiKey": "llama-cpp",
|
| 7 |
+
"compat": {
|
| 8 |
+
"supportsDeveloperRole": false,
|
| 9 |
+
"supportsReasoningEffort": false,
|
| 10 |
+
"supportsUsageInStreaming": false,
|
| 11 |
+
"maxTokensField": "max_tokens"
|
| 12 |
+
},
|
| 13 |
+
"models": [
|
| 14 |
+
{
|
| 15 |
+
"id": "unsloth/Qwen3.6-27B-MTP-GGUF",
|
| 16 |
+
"name": "Qwen 3.6 27B (local)",
|
| 17 |
+
"reasoning": false,
|
| 18 |
+
"input": ["text", "image"],
|
| 19 |
+
"contextWindow": 114688,
|
| 20 |
+
"maxTokens": 32768,
|
| 21 |
+
"cost": {
|
| 22 |
+
"input": 0,
|
| 23 |
+
"output": 0,
|
| 24 |
+
"cacheRead": 0,
|
| 25 |
+
"cacheWrite": 0
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
]
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
}
|
agent-redact/pi/agent/settings.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"defaultProvider": "llama-cpp",
|
| 3 |
+
"defaultModel": "unsloth/Qwen3.6-27B-MTP-GGUF",
|
| 4 |
+
"defaultThinkingLevel": "off",
|
| 5 |
+
"hideThinkingBlock": true,
|
| 6 |
+
"compaction": {
|
| 7 |
+
"enabled": true,
|
| 8 |
+
"reserveTokens": 32768,
|
| 9 |
+
"keepRecentTokens": 20000
|
| 10 |
+
},
|
| 11 |
+
"branchSummary": {
|
| 12 |
+
"skipPrompt": true,
|
| 13 |
+
"reserveTokens": 32768
|
| 14 |
+
},
|
| 15 |
+
"retry": {
|
| 16 |
+
"enabled": true,
|
| 17 |
+
"maxRetries": 5,
|
| 18 |
+
"baseDelayMs": 2000,
|
| 19 |
+
"provider": {
|
| 20 |
+
"timeoutMs": 3600000,
|
| 21 |
+
"maxRetries": 5,
|
| 22 |
+
"maxRetryDelayMs": 60000
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"enableSkillCommands": true,
|
| 26 |
+
"sessionDir": "sessions",
|
| 27 |
+
"steeringMode": "one-at-a-time",
|
| 28 |
+
"followUpMode": "one-at-a-time",
|
| 29 |
+
"terminal": {
|
| 30 |
+
"showTerminalProgress": false
|
| 31 |
+
}
|
| 32 |
+
}
|
agent-redact/pi/pi_agent_config.py
ADDED
|
@@ -0,0 +1,974 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate Pi agent models.json and settings.json at runtime."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
DEPLOYMENT_LOCAL = "local-docker"
|
| 11 |
+
DEPLOYMENT_HF_SPACE = "hf-space"
|
| 12 |
+
DEPLOYMENT_AWS_ECS = "aws-ecs"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def resolve_agent_dir() -> Path:
|
| 16 |
+
"""Directory for Pi ``models.json`` / ``settings.json`` (must be writable at runtime)."""
|
| 17 |
+
explicit = (os.environ.get("AGENT_CODING_AGENT_DIR") or "").strip()
|
| 18 |
+
if explicit:
|
| 19 |
+
return Path(explicit)
|
| 20 |
+
profile = (
|
| 21 |
+
os.environ.get("AGENT_DEPLOYMENT_PROFILE", DEPLOYMENT_LOCAL).strip().lower()
|
| 22 |
+
)
|
| 23 |
+
# HF Space and ECS often use a read-only root FS; only mounted paths (or /tmp) are writable.
|
| 24 |
+
if profile in (DEPLOYMENT_HF_SPACE, DEPLOYMENT_AWS_ECS):
|
| 25 |
+
return Path("/tmp/agent-coding")
|
| 26 |
+
return Path.home() / ".pi" / "agent"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# Back-compat alias; prefer resolve_agent_dir() when env may change after import.
|
| 30 |
+
AGENT_DIR = resolve_agent_dir()
|
| 31 |
+
TEMPLATE_DIR = Path(__file__).resolve().parent / "agent"
|
| 32 |
+
SETTINGS_TEMPLATE = TEMPLATE_DIR / "settings.json"
|
| 33 |
+
|
| 34 |
+
DEPLOYMENT_PROFILE = (
|
| 35 |
+
os.environ.get("AGENT_DEPLOYMENT_PROFILE", DEPLOYMENT_LOCAL).strip().lower()
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def pi_max_retries() -> int:
|
| 40 |
+
"""Max retries for Pi auto-retry and Gradio quota backoff (env: AGENT_MAX_RETRIES, default 5)."""
|
| 41 |
+
raw = (
|
| 42 |
+
os.environ.get("AGENT_QUOTA_RETRY_ATTEMPTS")
|
| 43 |
+
or os.environ.get("AGENT_MAX_RETRIES")
|
| 44 |
+
or "5"
|
| 45 |
+
).strip()
|
| 46 |
+
return int(raw)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _apply_retry_settings(
|
| 50 |
+
settings: dict[str, Any],
|
| 51 |
+
*,
|
| 52 |
+
provider: str,
|
| 53 |
+
) -> None:
|
| 54 |
+
"""Write Pi ``settings.json`` retry block (cloud providers use longer delays)."""
|
| 55 |
+
max_retries = pi_max_retries()
|
| 56 |
+
use_long_delays = (
|
| 57 |
+
provider == PROVIDER_GEMINI
|
| 58 |
+
or provider == PROVIDER_BEDROCK
|
| 59 |
+
or is_hf_space_profile()
|
| 60 |
+
or is_aws_ecs_profile()
|
| 61 |
+
)
|
| 62 |
+
base_delay_ms = 2000
|
| 63 |
+
max_delay_ms = 60000
|
| 64 |
+
if use_long_delays:
|
| 65 |
+
default_base_ms = int(os.environ.get("AGENT_QUOTA_RETRY_DELAY_S", "60")) * 1000
|
| 66 |
+
default_max_ms = int(default_base_ms * 1.5)
|
| 67 |
+
if provider == PROVIDER_BEDROCK or (
|
| 68 |
+
is_aws_ecs_profile() and not is_hf_space_profile()
|
| 69 |
+
):
|
| 70 |
+
prefix = "AGENT_BEDROCK"
|
| 71 |
+
else:
|
| 72 |
+
prefix = "AGENT_GEMINI"
|
| 73 |
+
base_delay_ms = int(
|
| 74 |
+
os.environ.get(f"{prefix}_RETRY_BASE_DELAY_MS")
|
| 75 |
+
or os.environ.get("AGENT_GEMINI_RETRY_BASE_DELAY_MS", str(default_base_ms))
|
| 76 |
+
)
|
| 77 |
+
max_delay_ms = int(
|
| 78 |
+
os.environ.get(f"{prefix}_RETRY_MAX_DELAY_MS")
|
| 79 |
+
or os.environ.get("AGENT_GEMINI_RETRY_MAX_DELAY_MS", str(default_max_ms))
|
| 80 |
+
)
|
| 81 |
+
settings["retry"] = {
|
| 82 |
+
"enabled": True,
|
| 83 |
+
"maxRetries": max_retries,
|
| 84 |
+
"baseDelayMs": base_delay_ms,
|
| 85 |
+
"provider": {
|
| 86 |
+
"timeoutMs": 3600000,
|
| 87 |
+
"maxRetries": max_retries,
|
| 88 |
+
"maxRetryDelayMs": max_delay_ms,
|
| 89 |
+
},
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
PROVIDER_LLAMA = "llama-cpp"
|
| 94 |
+
PROVIDER_GEMINI = "google-gemini"
|
| 95 |
+
PROVIDER_BEDROCK = "amazon-bedrock"
|
| 96 |
+
|
| 97 |
+
PROVIDER_LABELS: dict[str, str] = {
|
| 98 |
+
PROVIDER_LLAMA: "Local (llama-cpp)",
|
| 99 |
+
PROVIDER_GEMINI: "Gemini",
|
| 100 |
+
PROVIDER_BEDROCK: "AWS Bedrock",
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
# Pi RPC ``get_state`` provider ids (e.g. ``google``) β canonical config ids.
|
| 104 |
+
_PI_RPC_PROVIDER_ALIASES: dict[str, str] = {
|
| 105 |
+
"google": PROVIDER_GEMINI,
|
| 106 |
+
"gemini": PROVIDER_GEMINI,
|
| 107 |
+
"bedrock": PROVIDER_BEDROCK,
|
| 108 |
+
"llama": PROVIDER_LLAMA,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def is_hf_space_profile() -> bool:
|
| 113 |
+
profile = (
|
| 114 |
+
os.environ.get("AGENT_DEPLOYMENT_PROFILE", DEPLOYMENT_LOCAL).strip().lower()
|
| 115 |
+
)
|
| 116 |
+
return profile == DEPLOYMENT_HF_SPACE
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def is_aws_ecs_profile() -> bool:
|
| 120 |
+
profile = (
|
| 121 |
+
os.environ.get("AGENT_DEPLOYMENT_PROFILE", DEPLOYMENT_LOCAL).strip().lower()
|
| 122 |
+
)
|
| 123 |
+
return profile == DEPLOYMENT_AWS_ECS
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def uses_split_redaction_backend() -> bool:
|
| 127 |
+
"""
|
| 128 |
+
True when Pi and doc_redaction run in separate containers (no shared output disk).
|
| 129 |
+
|
| 130 |
+
HF Space and AWS ECS use Gradio HTTP download; local-docker typically shares a host
|
| 131 |
+
volume. Override with ``AGENT_REDACTION_SPLIT_BACKEND=true|false``.
|
| 132 |
+
"""
|
| 133 |
+
explicit = (os.environ.get("AGENT_REDACTION_SPLIT_BACKEND") or "").strip().lower()
|
| 134 |
+
if explicit in {"1", "true", "yes", "on"}:
|
| 135 |
+
return True
|
| 136 |
+
if explicit in {"0", "false", "no", "off"}:
|
| 137 |
+
return False
|
| 138 |
+
return is_hf_space_profile() or is_aws_ecs_profile()
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def resolve_llama_base_url() -> str:
|
| 142 |
+
"""
|
| 143 |
+
OpenAI-compatible base URL for Pi's ``llama-cpp`` provider (includes ``/v1``).
|
| 144 |
+
|
| 145 |
+
Reads ``AGENT_LLAMA_BASE_URL``; also accepts legacy aliases
|
| 146 |
+
``AGENT_LLAMA_MODE_BASE_URL`` and ``AGENT_LLAMA_MODE__BASE_URL``.
|
| 147 |
+
"""
|
| 148 |
+
for key in (
|
| 149 |
+
"AGENT_LLAMA_BASE_URL",
|
| 150 |
+
"AGENT_LLAMA_MODE_BASE_URL",
|
| 151 |
+
):
|
| 152 |
+
raw = (os.environ.get(key) or "").strip().rstrip("/")
|
| 153 |
+
if raw:
|
| 154 |
+
return raw if raw.endswith("/v1") else f"{raw}/v1"
|
| 155 |
+
return "http://llama-inference:8080/v1"
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
LLAMA_BASE_URL = resolve_llama_base_url()
|
| 159 |
+
LLAMA_MODEL_ID = os.environ.get("AGENT_LLAMA_MODEL_ID", "unsloth/Qwen3.6-27B-MTP-GGUF")
|
| 160 |
+
LLAMA_CONTEXT = int(os.environ.get("AGENT_LLAMA_CONTEXT_WINDOW", "114688"))
|
| 161 |
+
LLAMA_MAX_TOKENS = int(os.environ.get("AGENT_LLAMA_MAX_TOKENS", "32768"))
|
| 162 |
+
|
| 163 |
+
GEMINI_MODELS: tuple[tuple[str, str, int, bool], ...] = (
|
| 164 |
+
("gemini-flash-lite-latest", "Gemini Flash Lite", 1048576, False),
|
| 165 |
+
("gemini-flash-latest", "Gemini Flash", 1048576, True),
|
| 166 |
+
("gemini-pro-latest", "Gemini Pro", 1048576, True),
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
BEDROCK_MODELS: tuple[tuple[str, str, int, bool], ...] = (
|
| 170 |
+
(
|
| 171 |
+
"anthropic.claude-sonnet-4-6",
|
| 172 |
+
"Anthropic Claude Sonnet 4.6 (Bedrock)",
|
| 173 |
+
1000000,
|
| 174 |
+
True,
|
| 175 |
+
),
|
| 176 |
+
("amazon.nova-pro-v1:0", "Amazon Nova Pro (Bedrock)", 300000, False),
|
| 177 |
+
(
|
| 178 |
+
"nvidia.nemotron-super-3-120b",
|
| 179 |
+
"NVIDIA Nemotron Super 3 120B (Bedrock)",
|
| 180 |
+
262000,
|
| 181 |
+
False,
|
| 182 |
+
),
|
| 183 |
+
("mistral.devstral-2-123b", "Mistral Devstral 2 123B (Bedrock)", 256000, False),
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
PROVIDER_MODELS: dict[str, list[str]] = {
|
| 187 |
+
PROVIDER_LLAMA: [LLAMA_MODEL_ID],
|
| 188 |
+
PROVIDER_GEMINI: [model_id for model_id, _, _, _ in GEMINI_MODELS],
|
| 189 |
+
PROVIDER_BEDROCK: [model_id for model_id, _, _, _ in BEDROCK_MODELS],
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
DEFAULT_MODEL_BY_PROVIDER: dict[str, str] = {
|
| 193 |
+
PROVIDER_LLAMA: LLAMA_MODEL_ID,
|
| 194 |
+
PROVIDER_GEMINI: GEMINI_MODELS[0][0], # Gemini Flash Lite
|
| 195 |
+
PROVIDER_BEDROCK: "anthropic.claude-sonnet-4-6",
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def get_default_provider() -> str:
|
| 200 |
+
"""Current default Pi provider (reads ``AGENT_DEFAULT_PROVIDER`` from env each call)."""
|
| 201 |
+
if is_hf_space_profile():
|
| 202 |
+
return PROVIDER_GEMINI
|
| 203 |
+
raw = (os.environ.get("AGENT_DEFAULT_PROVIDER") or "").strip()
|
| 204 |
+
if raw in PROVIDER_MODELS:
|
| 205 |
+
return raw
|
| 206 |
+
if is_aws_ecs_profile():
|
| 207 |
+
return PROVIDER_BEDROCK
|
| 208 |
+
return PROVIDER_LLAMA
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
DEFAULT_PROVIDER = get_default_provider()
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _catalog_contains_model(model_id: str, provider: str) -> bool:
|
| 215 |
+
"""True when *model_id* is listed for a non-llama *provider*."""
|
| 216 |
+
return model_id in PROVIDER_MODELS.get(provider, ())
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
_env_default_model = (os.environ.get("AGENT_DEFAULT_MODEL") or "").strip()
|
| 220 |
+
if _env_default_model and (
|
| 221 |
+
DEFAULT_PROVIDER == PROVIDER_LLAMA
|
| 222 |
+
or _catalog_contains_model(_env_default_model, DEFAULT_PROVIDER)
|
| 223 |
+
):
|
| 224 |
+
DEFAULT_MODEL = _env_default_model
|
| 225 |
+
else:
|
| 226 |
+
DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.get(DEFAULT_PROVIDER, LLAMA_MODEL_ID)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def llama_model_id() -> str:
|
| 230 |
+
"""Active llama-cpp model id (runtime ``AGENT_LLAMA_MODEL_ID`` or startup default)."""
|
| 231 |
+
return (
|
| 232 |
+
os.environ.get("AGENT_LLAMA_MODEL_ID") or LLAMA_MODEL_ID
|
| 233 |
+
).strip() or LLAMA_MODEL_ID
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def resolved_default_model(provider: str, *, override: str | None = None) -> str:
|
| 237 |
+
"""
|
| 238 |
+
Pick the default model id for a provider.
|
| 239 |
+
|
| 240 |
+
Order: explicit override β ``AGENT_DEFAULT_MODEL`` when valid for *provider* β
|
| 241 |
+
built-in per-provider default (llama uses ``AGENT_LLAMA_MODEL_ID``).
|
| 242 |
+
"""
|
| 243 |
+
if override and override.strip():
|
| 244 |
+
return override.strip()
|
| 245 |
+
normalized = normalize_provider(provider)
|
| 246 |
+
env_model = (os.environ.get("AGENT_DEFAULT_MODEL") or "").strip()
|
| 247 |
+
active_provider = normalize_provider(get_default_provider())
|
| 248 |
+
if env_model:
|
| 249 |
+
if normalized == PROVIDER_LLAMA:
|
| 250 |
+
if active_provider == PROVIDER_LLAMA:
|
| 251 |
+
return env_model
|
| 252 |
+
elif _catalog_contains_model(env_model, normalized):
|
| 253 |
+
return env_model
|
| 254 |
+
if normalized == PROVIDER_LLAMA:
|
| 255 |
+
return llama_model_id()
|
| 256 |
+
return DEFAULT_MODEL_BY_PROVIDER.get(normalized, LLAMA_MODEL_ID)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def normalize_backend_model(provider: str, model_id: str | None) -> str:
|
| 260 |
+
"""
|
| 261 |
+
Resolve a UI/backend model selection to a concrete model id.
|
| 262 |
+
|
| 263 |
+
llama-cpp accepts any non-empty id (llama-swap / custom OpenAI model names).
|
| 264 |
+
Other providers must match the static catalog.
|
| 265 |
+
"""
|
| 266 |
+
normalized = normalize_provider(provider)
|
| 267 |
+
model = (model_id or default_model_for_provider(normalized)).strip()
|
| 268 |
+
if not model:
|
| 269 |
+
return default_model_for_provider(normalized)
|
| 270 |
+
if normalized == PROVIDER_LLAMA:
|
| 271 |
+
return model
|
| 272 |
+
if model in models_for_provider(normalized):
|
| 273 |
+
return model
|
| 274 |
+
return default_model_for_provider(normalized)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _zero_cost() -> dict[str, int]:
|
| 278 |
+
return {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _model_entry(
|
| 282 |
+
model_id: str,
|
| 283 |
+
name: str,
|
| 284 |
+
*,
|
| 285 |
+
context_window: int,
|
| 286 |
+
max_tokens: int,
|
| 287 |
+
reasoning: bool,
|
| 288 |
+
image_input: bool = True,
|
| 289 |
+
) -> dict[str, Any]:
|
| 290 |
+
inputs = ["text", "image"] if image_input else ["text"]
|
| 291 |
+
return {
|
| 292 |
+
"id": model_id,
|
| 293 |
+
"name": name,
|
| 294 |
+
"reasoning": reasoning,
|
| 295 |
+
"input": inputs,
|
| 296 |
+
"contextWindow": context_window,
|
| 297 |
+
"maxTokens": max_tokens,
|
| 298 |
+
"cost": _zero_cost(),
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _llama_provider() -> dict[str, Any]:
|
| 303 |
+
model_id = llama_model_id()
|
| 304 |
+
return {
|
| 305 |
+
"baseUrl": LLAMA_BASE_URL,
|
| 306 |
+
"api": "openai-completions",
|
| 307 |
+
"apiKey": "llama-cpp",
|
| 308 |
+
"compat": {
|
| 309 |
+
"supportsDeveloperRole": False,
|
| 310 |
+
"supportsReasoningEffort": False,
|
| 311 |
+
"supportsUsageInStreaming": False,
|
| 312 |
+
"maxTokensField": "max_tokens",
|
| 313 |
+
},
|
| 314 |
+
"models": [
|
| 315 |
+
_model_entry(
|
| 316 |
+
model_id,
|
| 317 |
+
f"Local ({model_id})",
|
| 318 |
+
context_window=LLAMA_CONTEXT,
|
| 319 |
+
max_tokens=LLAMA_MAX_TOKENS,
|
| 320 |
+
reasoning=False,
|
| 321 |
+
)
|
| 322 |
+
],
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _gemini_provider() -> dict[str, Any]:
|
| 327 |
+
return {
|
| 328 |
+
"baseUrl": "https://generativelanguage.googleapis.com/v1beta",
|
| 329 |
+
"api": "google-generative-ai",
|
| 330 |
+
"apiKey": "GEMINI_API_KEY",
|
| 331 |
+
"models": [
|
| 332 |
+
_model_entry(
|
| 333 |
+
model_id, name, context_window=ctx, max_tokens=8192, reasoning=reasoning
|
| 334 |
+
)
|
| 335 |
+
for model_id, name, ctx, reasoning in GEMINI_MODELS
|
| 336 |
+
],
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def _bedrock_region() -> str:
|
| 341 |
+
return (
|
| 342 |
+
os.environ.get("AWS_REGION")
|
| 343 |
+
or os.environ.get("AWS_DEFAULT_REGION")
|
| 344 |
+
or "eu-west-2"
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
_AWS_CREDENTIAL_ENV_KEYS: tuple[str, ...] = (
|
| 349 |
+
"AWS_ACCESS_KEY_ID",
|
| 350 |
+
"AWS_SECRET_ACCESS_KEY",
|
| 351 |
+
"AWS_SESSION_TOKEN",
|
| 352 |
+
"AWS_ACCESS_KEY",
|
| 353 |
+
"AWS_SECRET_KEY",
|
| 354 |
+
)
|
| 355 |
+
_AWS_PROFILE_ENV_KEYS: tuple[str, ...] = ("AWS_PROFILE", "AGENT_AWS_PROFILE")
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _env_flag(name: str, *, default: bool = False) -> bool:
|
| 359 |
+
raw = os.environ.get(name)
|
| 360 |
+
if raw is None:
|
| 361 |
+
return default
|
| 362 |
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _strip_empty_env_vars(names: tuple[str, ...]) -> None:
|
| 366 |
+
for name in names:
|
| 367 |
+
if not (os.environ.get(name) or "").strip():
|
| 368 |
+
os.environ.pop(name, None)
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _mirror_legacy_aws_key_env_vars() -> None:
|
| 372 |
+
if not (os.environ.get("AWS_ACCESS_KEY_ID") or "").strip():
|
| 373 |
+
legacy = (os.environ.get("AWS_ACCESS_KEY") or "").strip()
|
| 374 |
+
if legacy:
|
| 375 |
+
os.environ["AWS_ACCESS_KEY_ID"] = legacy
|
| 376 |
+
if not (os.environ.get("AWS_SECRET_ACCESS_KEY") or "").strip():
|
| 377 |
+
legacy = (os.environ.get("AWS_SECRET_KEY") or "").strip()
|
| 378 |
+
if legacy:
|
| 379 |
+
os.environ["AWS_SECRET_ACCESS_KEY"] = legacy
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _has_explicit_aws_access_keys() -> bool:
|
| 383 |
+
access = (
|
| 384 |
+
os.environ.get("AWS_ACCESS_KEY_ID") or os.environ.get("AWS_ACCESS_KEY") or ""
|
| 385 |
+
).strip()
|
| 386 |
+
secret = (
|
| 387 |
+
os.environ.get("AWS_SECRET_ACCESS_KEY")
|
| 388 |
+
or os.environ.get("AWS_SECRET_KEY")
|
| 389 |
+
or ""
|
| 390 |
+
).strip()
|
| 391 |
+
return bool(access and secret)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _aws_config_path() -> Path | None:
|
| 395 |
+
explicit = (os.environ.get("AWS_CONFIG_FILE") or "").strip()
|
| 396 |
+
if explicit:
|
| 397 |
+
path = Path(explicit).expanduser()
|
| 398 |
+
return path if path.is_file() else None
|
| 399 |
+
home = Path(os.environ.get("HOME", "/home/user"))
|
| 400 |
+
path = home / ".aws" / "config"
|
| 401 |
+
return path if path.is_file() else None
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def _discover_aws_profile_from_config() -> str | None:
|
| 405 |
+
"""Return an AWS profile name for Pi/Bedrock when only ~/.aws is mounted."""
|
| 406 |
+
explicit = (os.environ.get("AGENT_AWS_PROFILE") or "").strip()
|
| 407 |
+
if not explicit:
|
| 408 |
+
explicit = (os.environ.get("AWS_PROFILE") or "").strip()
|
| 409 |
+
if explicit:
|
| 410 |
+
return explicit
|
| 411 |
+
|
| 412 |
+
path = _aws_config_path()
|
| 413 |
+
if not path:
|
| 414 |
+
return None
|
| 415 |
+
|
| 416 |
+
current_profile: str | None = None
|
| 417 |
+
sso_profiles: list[str] = []
|
| 418 |
+
all_profiles: list[str] = []
|
| 419 |
+
|
| 420 |
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
| 421 |
+
line = raw_line.strip()
|
| 422 |
+
if not line or line.startswith("#") or line.startswith(";"):
|
| 423 |
+
continue
|
| 424 |
+
if line == "[default]":
|
| 425 |
+
current_profile = "default"
|
| 426 |
+
all_profiles.append("default")
|
| 427 |
+
continue
|
| 428 |
+
if line.startswith("[profile ") and line.endswith("]"):
|
| 429 |
+
current_profile = line[len("[profile ") : -1].strip()
|
| 430 |
+
if current_profile:
|
| 431 |
+
all_profiles.append(current_profile)
|
| 432 |
+
continue
|
| 433 |
+
if current_profile and line.startswith("sso_session"):
|
| 434 |
+
sso_profiles.append(current_profile)
|
| 435 |
+
|
| 436 |
+
if sso_profiles:
|
| 437 |
+
return sso_profiles[0]
|
| 438 |
+
if "default" in all_profiles:
|
| 439 |
+
return "default"
|
| 440 |
+
return all_profiles[0] if all_profiles else None
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def _region_from_aws_config(profile: str | None = None) -> str | None:
|
| 444 |
+
"""Read ``region =`` from a profile block in ``~/.aws/config``."""
|
| 445 |
+
path = _aws_config_path()
|
| 446 |
+
if not path:
|
| 447 |
+
return None
|
| 448 |
+
|
| 449 |
+
target = (profile or _discover_aws_profile_from_config() or "").strip()
|
| 450 |
+
if not target:
|
| 451 |
+
return None
|
| 452 |
+
|
| 453 |
+
current_profile: str | None = None
|
| 454 |
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
| 455 |
+
line = raw_line.strip()
|
| 456 |
+
if not line or line.startswith("#") or line.startswith(";"):
|
| 457 |
+
continue
|
| 458 |
+
if line == "[default]":
|
| 459 |
+
current_profile = "default"
|
| 460 |
+
continue
|
| 461 |
+
if line.startswith("[profile ") and line.endswith("]"):
|
| 462 |
+
current_profile = line[len("[profile ") : -1].strip()
|
| 463 |
+
continue
|
| 464 |
+
if current_profile != target:
|
| 465 |
+
continue
|
| 466 |
+
if line.startswith("region"):
|
| 467 |
+
_, _, value = line.partition("=")
|
| 468 |
+
region = value.strip()
|
| 469 |
+
if region:
|
| 470 |
+
return region
|
| 471 |
+
return None
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def _ensure_aws_region_env() -> None:
|
| 475 |
+
"""Ensure AWS SDK env has a non-empty region (profile config, then eu-west-2)."""
|
| 476 |
+
_strip_empty_env_vars(("AWS_REGION", "AWS_DEFAULT_REGION"))
|
| 477 |
+
region = (
|
| 478 |
+
os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or ""
|
| 479 |
+
).strip()
|
| 480 |
+
if not region:
|
| 481 |
+
profile = (os.environ.get("AWS_PROFILE") or "").strip()
|
| 482 |
+
region = (_region_from_aws_config(profile) or "").strip()
|
| 483 |
+
if not region:
|
| 484 |
+
region = _bedrock_region()
|
| 485 |
+
os.environ["AWS_REGION"] = region
|
| 486 |
+
os.environ["AWS_DEFAULT_REGION"] = region
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def _pi_bedrock_auth_visible() -> bool:
|
| 490 |
+
"""True when Pi's amazon-bedrock provider would detect configured auth."""
|
| 491 |
+
if (os.environ.get("AWS_PROFILE") or "").strip():
|
| 492 |
+
return True
|
| 493 |
+
if _has_explicit_aws_access_keys():
|
| 494 |
+
return True
|
| 495 |
+
if (os.environ.get("AWS_BEARER_TOKEN_BEDROCK") or "").strip():
|
| 496 |
+
return True
|
| 497 |
+
return False
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def _ensure_pi_bedrock_auth_env() -> None:
|
| 501 |
+
"""
|
| 502 |
+
Pi checks env vars (not ~/.aws alone) before Bedrock is usable.
|
| 503 |
+
|
| 504 |
+
When SSO credentials live in a mounted ``~/.aws`` tree, set ``AWS_PROFILE``
|
| 505 |
+
so Pi passes its auth preflight and the AWS SDK loads the profile.
|
| 506 |
+
"""
|
| 507 |
+
if _pi_bedrock_auth_visible():
|
| 508 |
+
return
|
| 509 |
+
profile = _discover_aws_profile_from_config()
|
| 510 |
+
if profile:
|
| 511 |
+
os.environ["AWS_PROFILE"] = profile
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def configure_aws_credentials(
|
| 515 |
+
*,
|
| 516 |
+
session_access_key_id: str | None = None,
|
| 517 |
+
session_secret_access_key: str | None = None,
|
| 518 |
+
session_session_token: str | None = None,
|
| 519 |
+
) -> None:
|
| 520 |
+
"""
|
| 521 |
+
Align Pi Bedrock AWS env with doc_redaction SSO/key priority.
|
| 522 |
+
|
| 523 |
+
Mirrors ``tools/file_redaction.py``: when ``RUN_AWS_FUNCTIONS`` is enabled,
|
| 524 |
+
prefer the default credential chain (SSO profile, instance role, etc.) over
|
| 525 |
+
static env keys when ``PRIORITISE_SSO_OVER_AWS_ENV_ACCESS_KEYS`` is true.
|
| 526 |
+
Explicit UI session keys from **Apply backend** always win.
|
| 527 |
+
"""
|
| 528 |
+
_strip_empty_env_vars(_AWS_CREDENTIAL_ENV_KEYS)
|
| 529 |
+
_strip_empty_env_vars(_AWS_PROFILE_ENV_KEYS)
|
| 530 |
+
_mirror_legacy_aws_key_env_vars()
|
| 531 |
+
|
| 532 |
+
session_explicit = bool(
|
| 533 |
+
session_access_key_id
|
| 534 |
+
and session_access_key_id.strip()
|
| 535 |
+
and session_secret_access_key
|
| 536 |
+
and session_secret_access_key.strip()
|
| 537 |
+
)
|
| 538 |
+
if session_explicit:
|
| 539 |
+
os.environ["AWS_ACCESS_KEY_ID"] = session_access_key_id.strip()
|
| 540 |
+
os.environ["AWS_SECRET_ACCESS_KEY"] = session_secret_access_key.strip()
|
| 541 |
+
if session_session_token and session_session_token.strip():
|
| 542 |
+
os.environ["AWS_SESSION_TOKEN"] = session_session_token.strip()
|
| 543 |
+
else:
|
| 544 |
+
os.environ.pop("AWS_SESSION_TOKEN", None)
|
| 545 |
+
_ensure_aws_region_env()
|
| 546 |
+
return
|
| 547 |
+
|
| 548 |
+
run_aws = _env_flag("RUN_AWS_FUNCTIONS")
|
| 549 |
+
prioritise_sso = _env_flag("PRIORITISE_SSO_OVER_AWS_ENV_ACCESS_KEYS", default=True)
|
| 550 |
+
|
| 551 |
+
if run_aws and prioritise_sso:
|
| 552 |
+
for key in _AWS_CREDENTIAL_ENV_KEYS:
|
| 553 |
+
os.environ.pop(key, None)
|
| 554 |
+
_ensure_pi_bedrock_auth_env()
|
| 555 |
+
elif run_aws:
|
| 556 |
+
for key in _AWS_CREDENTIAL_ENV_KEYS:
|
| 557 |
+
os.environ.pop(key, None)
|
| 558 |
+
_ensure_pi_bedrock_auth_env()
|
| 559 |
+
|
| 560 |
+
# Propagate AGENT_AWS_PROFILE when only that alias is set (e.g. agent.env).
|
| 561 |
+
pi_profile = (os.environ.get("AGENT_AWS_PROFILE") or "").strip()
|
| 562 |
+
if pi_profile and not (os.environ.get("AWS_PROFILE") or "").strip():
|
| 563 |
+
os.environ["AWS_PROFILE"] = pi_profile
|
| 564 |
+
|
| 565 |
+
_ensure_aws_region_env()
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def _aws_credential_status() -> str:
|
| 569 |
+
if _has_explicit_aws_access_keys():
|
| 570 |
+
return "access keys"
|
| 571 |
+
profile = (os.environ.get("AWS_PROFILE") or "").strip()
|
| 572 |
+
if profile:
|
| 573 |
+
return f"profile {profile}"
|
| 574 |
+
if (os.environ.get("AWS_BEARER_TOKEN_BEDROCK") or "").strip():
|
| 575 |
+
return "Bedrock bearer token"
|
| 576 |
+
if _aws_config_path():
|
| 577 |
+
return "SSO config mounted (profile not set)"
|
| 578 |
+
if _env_flag("RUN_AWS_FUNCTIONS"):
|
| 579 |
+
return "SSO/default chain (missing profile)"
|
| 580 |
+
return "missing"
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
def _bedrock_provider() -> dict[str, Any]:
|
| 584 |
+
region = _bedrock_region()
|
| 585 |
+
return {
|
| 586 |
+
"baseUrl": f"https://bedrock-runtime.{region}.amazonaws.com",
|
| 587 |
+
"api": "bedrock-converse-stream",
|
| 588 |
+
"models": [
|
| 589 |
+
_model_entry(
|
| 590 |
+
model_id,
|
| 591 |
+
name,
|
| 592 |
+
context_window=ctx,
|
| 593 |
+
max_tokens=8192,
|
| 594 |
+
reasoning=reasoning,
|
| 595 |
+
)
|
| 596 |
+
for model_id, name, ctx, reasoning in BEDROCK_MODELS
|
| 597 |
+
],
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
def build_models_config() -> dict[str, Any]:
|
| 602 |
+
if is_hf_space_profile():
|
| 603 |
+
return {"providers": {PROVIDER_GEMINI: _gemini_provider()}}
|
| 604 |
+
return {
|
| 605 |
+
"providers": {
|
| 606 |
+
PROVIDER_LLAMA: _llama_provider(),
|
| 607 |
+
PROVIDER_GEMINI: _gemini_provider(),
|
| 608 |
+
PROVIDER_BEDROCK: _bedrock_provider(),
|
| 609 |
+
}
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def _load_settings_template() -> dict[str, Any]:
|
| 614 |
+
if SETTINGS_TEMPLATE.is_file():
|
| 615 |
+
return json.loads(SETTINGS_TEMPLATE.read_text(encoding="utf-8"))
|
| 616 |
+
return {
|
| 617 |
+
"defaultThinkingLevel": "off",
|
| 618 |
+
"hideThinkingBlock": True,
|
| 619 |
+
"compaction": {
|
| 620 |
+
"enabled": True,
|
| 621 |
+
"reserveTokens": 32768,
|
| 622 |
+
"keepRecentTokens": 20000,
|
| 623 |
+
},
|
| 624 |
+
"enableSkillCommands": True,
|
| 625 |
+
"sessionDir": "sessions",
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
def _apply_compaction_settings(settings: dict[str, Any]) -> None:
|
| 630 |
+
"""
|
| 631 |
+
Merge Pi session auto-compaction from env into ``settings.json``.
|
| 632 |
+
|
| 633 |
+
``AGENT_COMPACTION_ENABLED`` β when set, overrides the template ``compaction.enabled``
|
| 634 |
+
flag (``true`` / ``false``). When unset, the template default applies (enabled).
|
| 635 |
+
|
| 636 |
+
Optional tuning: ``AGENT_COMPACTION_RESERVE_TOKENS``, ``AGENT_COMPACTION_KEEP_RECENT_TOKENS``.
|
| 637 |
+
"""
|
| 638 |
+
compaction = dict(
|
| 639 |
+
settings.get("compaction")
|
| 640 |
+
or {
|
| 641 |
+
"enabled": True,
|
| 642 |
+
"reserveTokens": 32768,
|
| 643 |
+
"keepRecentTokens": 20000,
|
| 644 |
+
}
|
| 645 |
+
)
|
| 646 |
+
if os.environ.get("AGENT_COMPACTION_ENABLED") is not None:
|
| 647 |
+
compaction["enabled"] = _env_flag("AGENT_COMPACTION_ENABLED")
|
| 648 |
+
reserve = (os.environ.get("AGENT_COMPACTION_RESERVE_TOKENS") or "").strip()
|
| 649 |
+
if reserve:
|
| 650 |
+
compaction["reserveTokens"] = int(reserve)
|
| 651 |
+
elif LLAMA_CONTEXT < 100_000:
|
| 652 |
+
# Smaller local models (e.g. Gemma 4 31B at 65536): default reserve was 32768.
|
| 653 |
+
compaction["reserveTokens"] = min(16_384, max(8_192, LLAMA_CONTEXT // 4))
|
| 654 |
+
keep = (os.environ.get("AGENT_COMPACTION_KEEP_RECENT_TOKENS") or "").strip()
|
| 655 |
+
if keep:
|
| 656 |
+
compaction["keepRecentTokens"] = int(keep)
|
| 657 |
+
elif LLAMA_CONTEXT < 100_000:
|
| 658 |
+
compaction["keepRecentTokens"] = min(12_288, max(4_096, LLAMA_CONTEXT // 5))
|
| 659 |
+
settings["compaction"] = compaction
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
def resolve_session_dir() -> str:
|
| 663 |
+
"""Pi session JSONL directory (absolute path or relative to ``AGENT_DIR``)."""
|
| 664 |
+
explicit = os.environ.get("AGENT_SESSION_DIR", "").strip()
|
| 665 |
+
if explicit:
|
| 666 |
+
return explicit
|
| 667 |
+
if is_hf_space_profile():
|
| 668 |
+
return "/tmp/agent-sessions"
|
| 669 |
+
return "sessions"
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
def ensure_session_dir(session_dir: str | None = None) -> Path:
|
| 673 |
+
"""Create the Pi session directory and return its resolved absolute path."""
|
| 674 |
+
raw = (session_dir or resolve_session_dir()).strip()
|
| 675 |
+
path = Path(raw)
|
| 676 |
+
if not path.is_absolute():
|
| 677 |
+
path = (resolve_agent_dir() / path).resolve()
|
| 678 |
+
else:
|
| 679 |
+
path = path.resolve()
|
| 680 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 681 |
+
return path
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def configure_pi_coding_agent_env() -> None:
|
| 685 |
+
"""
|
| 686 |
+
Mirror ``AGENT_*`` paths into Pi CLI env vars.
|
| 687 |
+
|
| 688 |
+
The external ``pi`` binary reads ``PI_CODING_AGENT_DIR`` (models/settings) and
|
| 689 |
+
``PI_CODING_AGENT_SESSION_DIR`` (session JSONL), not our ``AGENT_*`` names.
|
| 690 |
+
HF Space / ECS images write config under ``/tmp``; without this mirror Pi falls
|
| 691 |
+
back to ``~/.pi/agent``, which is often not writable on Spaces.
|
| 692 |
+
"""
|
| 693 |
+
agent_dir = str(resolve_agent_dir())
|
| 694 |
+
session_dir = str(ensure_session_dir(resolve_session_dir()))
|
| 695 |
+
os.environ.setdefault("PI_CODING_AGENT_DIR", agent_dir)
|
| 696 |
+
os.environ.setdefault("PI_CODING_AGENT_SESSION_DIR", session_dir)
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def build_settings_config(
|
| 700 |
+
*,
|
| 701 |
+
default_provider: str | None = None,
|
| 702 |
+
default_model: str | None = None,
|
| 703 |
+
) -> dict[str, Any]:
|
| 704 |
+
provider = default_provider or get_default_provider()
|
| 705 |
+
if provider not in PROVIDER_MODELS:
|
| 706 |
+
provider = PROVIDER_GEMINI if is_hf_space_profile() else PROVIDER_LLAMA
|
| 707 |
+
model = resolved_default_model(provider, override=default_model)
|
| 708 |
+
|
| 709 |
+
settings = _load_settings_template()
|
| 710 |
+
settings["defaultProvider"] = provider
|
| 711 |
+
settings["defaultModel"] = model
|
| 712 |
+
_apply_compaction_settings(settings)
|
| 713 |
+
session_path = ensure_session_dir(resolve_session_dir())
|
| 714 |
+
settings["sessionDir"] = session_path.as_posix()
|
| 715 |
+
if (
|
| 716 |
+
is_hf_space_profile()
|
| 717 |
+
or is_aws_ecs_profile()
|
| 718 |
+
or provider in (PROVIDER_GEMINI, PROVIDER_BEDROCK)
|
| 719 |
+
):
|
| 720 |
+
_apply_retry_settings(settings, provider=provider)
|
| 721 |
+
from pi_workspace_skills import ensure_workspace_skills, workspace_skills_dir
|
| 722 |
+
|
| 723 |
+
ensure_workspace_skills()
|
| 724 |
+
settings["skills"] = [workspace_skills_dir().as_posix()]
|
| 725 |
+
return settings
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
def write_runtime_config(
|
| 729 |
+
*,
|
| 730 |
+
agent_dir: Path | None = None,
|
| 731 |
+
default_provider: str | None = None,
|
| 732 |
+
default_model: str | None = None,
|
| 733 |
+
) -> tuple[Path, Path]:
|
| 734 |
+
"""Write models.json and settings.json; return their paths."""
|
| 735 |
+
provider = normalize_provider(default_provider or get_default_provider())
|
| 736 |
+
if default_provider:
|
| 737 |
+
os.environ["AGENT_DEFAULT_PROVIDER"] = provider
|
| 738 |
+
if default_model and default_model.strip():
|
| 739 |
+
model = default_model.strip()
|
| 740 |
+
os.environ["AGENT_DEFAULT_MODEL"] = model
|
| 741 |
+
if provider == PROVIDER_LLAMA:
|
| 742 |
+
os.environ["AGENT_LLAMA_MODEL_ID"] = model
|
| 743 |
+
|
| 744 |
+
target = Path(agent_dir or resolve_agent_dir())
|
| 745 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 746 |
+
|
| 747 |
+
models_path = target / "models.json"
|
| 748 |
+
settings_path = target / "settings.json"
|
| 749 |
+
|
| 750 |
+
models_path.write_text(
|
| 751 |
+
json.dumps(build_models_config(), indent=2) + "\n",
|
| 752 |
+
encoding="utf-8",
|
| 753 |
+
)
|
| 754 |
+
settings_path.write_text(
|
| 755 |
+
json.dumps(
|
| 756 |
+
build_settings_config(
|
| 757 |
+
default_provider=default_provider,
|
| 758 |
+
default_model=default_model,
|
| 759 |
+
),
|
| 760 |
+
indent=2,
|
| 761 |
+
)
|
| 762 |
+
+ "\n",
|
| 763 |
+
encoding="utf-8",
|
| 764 |
+
)
|
| 765 |
+
configure_pi_coding_agent_env()
|
| 766 |
+
return models_path, settings_path
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
def models_for_provider(provider: str) -> list[str]:
|
| 770 |
+
if is_hf_space_profile():
|
| 771 |
+
return list(PROVIDER_MODELS[PROVIDER_GEMINI])
|
| 772 |
+
if provider == PROVIDER_LLAMA:
|
| 773 |
+
return [llama_model_id()]
|
| 774 |
+
return list(PROVIDER_MODELS.get(provider, PROVIDER_MODELS[PROVIDER_LLAMA]))
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
def default_model_for_provider(provider: str) -> str:
|
| 778 |
+
return resolved_default_model(provider)
|
| 779 |
+
|
| 780 |
+
|
| 781 |
+
def normalize_provider(provider: str) -> str:
|
| 782 |
+
label_map = {label.lower(): key for key, label in PROVIDER_LABELS.items()}
|
| 783 |
+
lowered = (provider or "").strip().lower()
|
| 784 |
+
if lowered in PROVIDER_MODELS:
|
| 785 |
+
return lowered
|
| 786 |
+
if lowered in _PI_RPC_PROVIDER_ALIASES:
|
| 787 |
+
return _PI_RPC_PROVIDER_ALIASES[lowered]
|
| 788 |
+
if lowered in label_map:
|
| 789 |
+
return label_map[lowered]
|
| 790 |
+
return PROVIDER_GEMINI if is_hf_space_profile() else PROVIDER_LLAMA
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
def active_model_from_pi_state(state: dict[str, Any]) -> tuple[str, str]:
|
| 794 |
+
"""Return ``(normalized_provider, model_id)`` from a Pi RPC ``get_state`` payload."""
|
| 795 |
+
if not isinstance(state, dict):
|
| 796 |
+
return "", ""
|
| 797 |
+
model = state.get("model") or {}
|
| 798 |
+
if not isinstance(model, dict):
|
| 799 |
+
model = {}
|
| 800 |
+
provider_raw = str(model.get("provider") or state.get("provider") or "").strip()
|
| 801 |
+
model_id = str(model.get("id") or model.get("name") or "").strip()
|
| 802 |
+
return normalize_provider(provider_raw), model_id
|
| 803 |
+
|
| 804 |
+
|
| 805 |
+
def pi_model_fallback_notice(
|
| 806 |
+
*,
|
| 807 |
+
intended_provider: str,
|
| 808 |
+
intended_model: str,
|
| 809 |
+
active_provider: str,
|
| 810 |
+
active_model: str,
|
| 811 |
+
) -> str | None:
|
| 812 |
+
"""
|
| 813 |
+
User-facing warning when Pi runs a different provider/model than configured.
|
| 814 |
+
|
| 815 |
+
Returns ``None`` when the active model matches the intended configuration.
|
| 816 |
+
"""
|
| 817 |
+
intended_p = normalize_provider(intended_provider)
|
| 818 |
+
intended_m = (intended_model or "").strip()
|
| 819 |
+
active_p = normalize_provider(active_provider)
|
| 820 |
+
active_m = (active_model or "").strip()
|
| 821 |
+
if not active_m:
|
| 822 |
+
return None
|
| 823 |
+
if intended_p == active_p and intended_m.casefold() == active_m.casefold():
|
| 824 |
+
return None
|
| 825 |
+
intended_label = (
|
| 826 |
+
f"{PROVIDER_LABELS.get(intended_p, intended_p)} / `{intended_m or 'β'}`"
|
| 827 |
+
)
|
| 828 |
+
active_label = f"{PROVIDER_LABELS.get(active_p, active_p)} / `{active_m}`"
|
| 829 |
+
llama_hint = ""
|
| 830 |
+
if intended_p == PROVIDER_LLAMA:
|
| 831 |
+
llama_hint = (
|
| 832 |
+
f" Check your llama.cpp model id (`GET {LLAMA_BASE_URL.rstrip('/')}/models`) "
|
| 833 |
+
f"and align `AGENT_DEFAULT_MODEL` / `AGENT_LLAMA_MODEL_ID` in "
|
| 834 |
+
f"`config/agent.env`."
|
| 835 |
+
)
|
| 836 |
+
cloud_hint = ""
|
| 837 |
+
if active_p == PROVIDER_GEMINI and intended_p == PROVIDER_LLAMA:
|
| 838 |
+
cloud_hint = (
|
| 839 |
+
" A Gemini API key in the container environment can trigger this fallback."
|
| 840 |
+
)
|
| 841 |
+
return (
|
| 842 |
+
f"The configured orchestration model {intended_label} was not found or could "
|
| 843 |
+
f"not be selected. Pi is using **{active_label}** instead.{llama_hint}"
|
| 844 |
+
f"{cloud_hint} Click **Apply backend** with the correct model, or recreate "
|
| 845 |
+
f"`pi-agent` after updating `config/agent.env`."
|
| 846 |
+
)
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
def pi_model_fallback_notice_from_state(
|
| 850 |
+
state: dict[str, Any],
|
| 851 |
+
intended_provider: str,
|
| 852 |
+
intended_model: str,
|
| 853 |
+
) -> str | None:
|
| 854 |
+
"""Build a fallback notice from Pi RPC state and the configured provider/model."""
|
| 855 |
+
active_p, active_m = active_model_from_pi_state(state)
|
| 856 |
+
return pi_model_fallback_notice(
|
| 857 |
+
intended_provider=intended_provider,
|
| 858 |
+
intended_model=intended_model,
|
| 859 |
+
active_provider=active_p,
|
| 860 |
+
active_model=active_m,
|
| 861 |
+
)
|
| 862 |
+
|
| 863 |
+
|
| 864 |
+
def apply_session_credentials(
|
| 865 |
+
*,
|
| 866 |
+
gemini_api_key: str | None = None,
|
| 867 |
+
hf_token: str | None = None,
|
| 868 |
+
aws_region: str | None = None,
|
| 869 |
+
aws_access_key_id: str | None = None,
|
| 870 |
+
aws_secret_access_key: str | None = None,
|
| 871 |
+
aws_session_token: str | None = None,
|
| 872 |
+
) -> None:
|
| 873 |
+
"""Apply session-only credential overrides to os.environ."""
|
| 874 |
+
if gemini_api_key and gemini_api_key.strip():
|
| 875 |
+
os.environ["GEMINI_API_KEY"] = gemini_api_key.strip()
|
| 876 |
+
if hf_token and hf_token.strip():
|
| 877 |
+
token = hf_token.strip()
|
| 878 |
+
os.environ["HF_TOKEN"] = token
|
| 879 |
+
os.environ["DOC_REDACTION_HF_TOKEN"] = token
|
| 880 |
+
if aws_region and aws_region.strip():
|
| 881 |
+
os.environ["AWS_REGION"] = aws_region.strip()
|
| 882 |
+
os.environ["AWS_DEFAULT_REGION"] = aws_region.strip()
|
| 883 |
+
configure_aws_credentials(
|
| 884 |
+
session_access_key_id=aws_access_key_id,
|
| 885 |
+
session_secret_access_key=aws_secret_access_key,
|
| 886 |
+
session_session_token=aws_session_token,
|
| 887 |
+
)
|
| 888 |
+
|
| 889 |
+
|
| 890 |
+
def mirror_hf_token_from_env() -> None:
|
| 891 |
+
"""Mirror DOC_REDACTION_HF_TOKEN or Space secret HF_TOKEN for Pi subprocess."""
|
| 892 |
+
if os.environ.get("HF_TOKEN"):
|
| 893 |
+
return
|
| 894 |
+
doc_token = os.environ.get("DOC_REDACTION_HF_TOKEN", "").strip()
|
| 895 |
+
if doc_token:
|
| 896 |
+
os.environ["HF_TOKEN"] = doc_token
|
| 897 |
+
|
| 898 |
+
|
| 899 |
+
def _hf_token_status() -> str:
|
| 900 |
+
if os.environ.get("HF_TOKEN"):
|
| 901 |
+
source = (
|
| 902 |
+
"UI session" if os.environ.get("_HF_TOKEN_FROM_UI") else "env/Space secret"
|
| 903 |
+
)
|
| 904 |
+
return f"set ({source})"
|
| 905 |
+
return "missing"
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
def credential_status_markdown(*, provider: str | None = None) -> str:
|
| 909 |
+
"""
|
| 910 |
+
Credential summary for the active Pi provider.
|
| 911 |
+
|
| 912 |
+
``llama-cpp`` uses the local OpenAI-compatible endpoint only (no Gemini/AWS keys).
|
| 913 |
+
Gemini and Bedrock lines appear only when that provider is selected.
|
| 914 |
+
"""
|
| 915 |
+
active = normalize_provider(provider or get_default_provider())
|
| 916 |
+
orchestrator = (os.environ.get("AGENT_ORCHESTRATOR") or "pi").strip().lower()
|
| 917 |
+
if orchestrator in {"agentcore", "agentcore-harness"}:
|
| 918 |
+
try:
|
| 919 |
+
from redaction_prompt import doc_redaction_gradio_url
|
| 920 |
+
|
| 921 |
+
backend = doc_redaction_gradio_url()
|
| 922 |
+
except ImportError:
|
| 923 |
+
backend = (os.environ.get("DOC_REDACTION_GRADIO_URL") or "β").strip()
|
| 924 |
+
region = _bedrock_region()
|
| 925 |
+
return (
|
| 926 |
+
f"**Credentials:** AWS `{_aws_credential_status()}` Β· region `{region}` "
|
| 927 |
+
f"(AgentCore orchestration) \n"
|
| 928 |
+
f"**Redaction tools call:** `{backend}` (sent to runtime each invoke)"
|
| 929 |
+
)
|
| 930 |
+
if is_hf_space_profile():
|
| 931 |
+
gemini = (
|
| 932 |
+
"set"
|
| 933 |
+
if os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
| 934 |
+
else "missing"
|
| 935 |
+
)
|
| 936 |
+
return (
|
| 937 |
+
f"**Credentials:** Gemini `{gemini}` Β· "
|
| 938 |
+
f"HF token (redaction backend) `{_hf_token_status()}`"
|
| 939 |
+
)
|
| 940 |
+
if active == PROVIDER_LLAMA:
|
| 941 |
+
return (
|
| 942 |
+
f"**Credentials:** local llama-cpp at `{LLAMA_BASE_URL}` "
|
| 943 |
+
f"(no API key; AWS/Gemini not used for Pi orchestration)"
|
| 944 |
+
)
|
| 945 |
+
if active == PROVIDER_GEMINI:
|
| 946 |
+
gemini = (
|
| 947 |
+
"set"
|
| 948 |
+
if os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
| 949 |
+
else "missing"
|
| 950 |
+
)
|
| 951 |
+
return f"**Credentials:** Gemini `{gemini}`"
|
| 952 |
+
region = _bedrock_region()
|
| 953 |
+
return f"**Credentials:** AWS `{_aws_credential_status()}` Β· region `{region}`"
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
def provider_choices() -> list[str]:
|
| 957 |
+
if is_hf_space_profile():
|
| 958 |
+
return [PROVIDER_GEMINI]
|
| 959 |
+
return list(PROVIDER_LABELS.keys())
|
| 960 |
+
|
| 961 |
+
|
| 962 |
+
def gemini_api_key_configured() -> bool:
|
| 963 |
+
return bool(os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"))
|
| 964 |
+
|
| 965 |
+
|
| 966 |
+
def provider_label(provider: str) -> str:
|
| 967 |
+
return PROVIDER_LABELS.get(provider, provider)
|
| 968 |
+
|
| 969 |
+
|
| 970 |
+
if __name__ == "__main__":
|
| 971 |
+
configure_aws_credentials()
|
| 972 |
+
models_path, settings_path = write_runtime_config()
|
| 973 |
+
print(f"Wrote {models_path}")
|
| 974 |
+
print(f"Wrote {settings_path}")
|
agent-redact/pi/pi_rpc_client.py
ADDED
|
@@ -0,0 +1,1016 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Python client for Pi RPC mode (JSONL over stdin/stdout)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import queue
|
| 8 |
+
import shutil
|
| 9 |
+
import subprocess
|
| 10 |
+
import sys
|
| 11 |
+
import threading
|
| 12 |
+
import uuid
|
| 13 |
+
from collections import deque
|
| 14 |
+
from collections.abc import Iterator
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class PiRpcError(RuntimeError):
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# Sentinel pushed to every pending response slot and the events queue when the
|
| 24 |
+
# Pi RPC subprocess exits, so blocked waiters unblock with a clear error instead
|
| 25 |
+
# of hanging forever.
|
| 26 |
+
_PI_PROCESS_EXIT = object()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# Pi RPC is JSONL over pipes; always UTF-8 (Windows default locale is cp1252).
|
| 30 |
+
_PI_SUBPROCESS_ENCODING = "utf-8"
|
| 31 |
+
_PI_SUBPROCESS_ENCODING_ERRORS = "replace"
|
| 32 |
+
|
| 33 |
+
_PI_INSTALL_HINT = (
|
| 34 |
+
"Install the Pi coding agent CLI, then restart the Gradio app: \n"
|
| 35 |
+
"`npm install -g @earendil-works/pi-coding-agent` \n"
|
| 36 |
+
"On Windows, ensure Node.js/npm are on PATH (or set `AGENT_EXECUTABLE` to the "
|
| 37 |
+
"full path to `pi.cmd`, e.g. `%APPDATA%\\npm\\pi.cmd`). \n"
|
| 38 |
+
"Docker users: run the Pi UI via `docker compose` (`pi-agent` service) instead "
|
| 39 |
+
"of `python gradio_app.py` on the host."
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def resolve_pi_executable() -> str:
|
| 44 |
+
"""Return a path to the ``pi`` RPC executable (raises ``PiRpcError`` if missing)."""
|
| 45 |
+
override = os.environ.get("AGENT_EXECUTABLE", "").strip()
|
| 46 |
+
if override:
|
| 47 |
+
if os.path.isfile(override) or shutil.which(override):
|
| 48 |
+
return override
|
| 49 |
+
raise PiRpcError(
|
| 50 |
+
f"AGENT_EXECUTABLE is set but not found: `{override}` \n\n{_PI_INSTALL_HINT}"
|
| 51 |
+
)
|
| 52 |
+
for name in ("pi", "pi.cmd"):
|
| 53 |
+
found = shutil.which(name)
|
| 54 |
+
if found:
|
| 55 |
+
return found
|
| 56 |
+
raise PiRpcError(f"Pi CLI (`pi`) not found on PATH. \n\n{_PI_INSTALL_HINT}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class PiStreamEvent:
|
| 61 |
+
"""Structured event from Pi RPC for UI layers."""
|
| 62 |
+
|
| 63 |
+
kind: str
|
| 64 |
+
text: str = ""
|
| 65 |
+
tool_name: str | None = None
|
| 66 |
+
tool_call_id: str | None = None
|
| 67 |
+
tool_args: dict[str, Any] | None = None
|
| 68 |
+
tool_output: str | None = None
|
| 69 |
+
is_error: bool = False
|
| 70 |
+
meta: dict[str, Any] = field(default_factory=dict)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def extract_tool_text(payload: dict[str, Any] | None) -> str:
|
| 74 |
+
if not payload:
|
| 75 |
+
return ""
|
| 76 |
+
content = payload.get("content")
|
| 77 |
+
if content is None and isinstance(payload.get("partialResult"), dict):
|
| 78 |
+
content = payload["partialResult"].get("content")
|
| 79 |
+
if content is None and isinstance(payload.get("result"), dict):
|
| 80 |
+
content = payload["result"].get("content")
|
| 81 |
+
if not isinstance(content, list):
|
| 82 |
+
return ""
|
| 83 |
+
parts: list[str] = []
|
| 84 |
+
for block in content:
|
| 85 |
+
if isinstance(block, dict) and block.get("type") == "text":
|
| 86 |
+
parts.append(str(block.get("text") or ""))
|
| 87 |
+
return "\n".join(parts).strip()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def extract_assistant_display(message: dict[str, Any] | None) -> tuple[str, str]:
|
| 91 |
+
"""Extract visible text and thinking from a partial assistant message."""
|
| 92 |
+
if not message or message.get("role") != "assistant":
|
| 93 |
+
return "", ""
|
| 94 |
+
content = message.get("content")
|
| 95 |
+
if isinstance(content, str):
|
| 96 |
+
return content, ""
|
| 97 |
+
if not isinstance(content, list):
|
| 98 |
+
return "", ""
|
| 99 |
+
|
| 100 |
+
texts: list[str] = []
|
| 101 |
+
thinkings: list[str] = []
|
| 102 |
+
for block in content:
|
| 103 |
+
if isinstance(block, str):
|
| 104 |
+
if block.strip():
|
| 105 |
+
texts.append(block)
|
| 106 |
+
continue
|
| 107 |
+
if not isinstance(block, dict):
|
| 108 |
+
continue
|
| 109 |
+
block_type = block.get("type")
|
| 110 |
+
if block_type in (None, "text", "output_text"):
|
| 111 |
+
text = block.get("text") or block.get("content") or ""
|
| 112 |
+
if text:
|
| 113 |
+
texts.append(str(text))
|
| 114 |
+
elif block_type in ("thinking", "reasoning", "thought"):
|
| 115 |
+
thought = (
|
| 116 |
+
block.get("thinking")
|
| 117 |
+
or block.get("text")
|
| 118 |
+
or block.get("reasoning")
|
| 119 |
+
or block.get("content")
|
| 120 |
+
or ""
|
| 121 |
+
)
|
| 122 |
+
if thought:
|
| 123 |
+
thinkings.append(str(thought))
|
| 124 |
+
return "".join(texts), "".join(thinkings)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def assistant_chat_text(visible: str, thinking: str) -> str:
|
| 128 |
+
"""Text to show in the main chat β visible answer, or thinking when Gemini sends only that."""
|
| 129 |
+
if visible.strip():
|
| 130 |
+
return visible
|
| 131 |
+
return thinking
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _tool_lines_from_content(content: list[Any]) -> list[str]:
|
| 135 |
+
tool_lines: list[str] = []
|
| 136 |
+
for block in content:
|
| 137 |
+
if not isinstance(block, dict):
|
| 138 |
+
continue
|
| 139 |
+
block_type = block.get("type")
|
| 140 |
+
if block_type not in {"toolCall", "tool_use", "functionCall"}:
|
| 141 |
+
continue
|
| 142 |
+
name = str(block.get("name") or block.get("toolName") or "tool")
|
| 143 |
+
args = block.get("arguments") or block.get("input") or block.get("args")
|
| 144 |
+
if isinstance(args, str):
|
| 145 |
+
try:
|
| 146 |
+
args = json.loads(args)
|
| 147 |
+
except json.JSONDecodeError:
|
| 148 |
+
args = {"raw": args}
|
| 149 |
+
if not isinstance(args, dict):
|
| 150 |
+
args = {}
|
| 151 |
+
tool_lines.append(format_tool_chat_line(name, args))
|
| 152 |
+
return tool_lines
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def format_tool_chat_line(tool_name: str | None, args: dict[str, Any] | None) -> str:
|
| 156 |
+
"""Render one tool invocation for the chat UI (prose for comment-only bash)."""
|
| 157 |
+
name = str(tool_name or "tool")
|
| 158 |
+
lowered = name.lower()
|
| 159 |
+
if lowered == "bash" and args and args.get("command"):
|
| 160 |
+
cmd = str(args["command"])
|
| 161 |
+
if is_bash_commentary_only(cmd):
|
| 162 |
+
return extract_bash_commentary_text(cmd)
|
| 163 |
+
commentary, executable = split_bash_commentary_and_command(cmd)
|
| 164 |
+
if commentary and executable:
|
| 165 |
+
short = executable[:200] + ("β¦" if len(executable) > 200 else "")
|
| 166 |
+
return f"{commentary}\n\n**bash:** `{short}`"
|
| 167 |
+
if commentary:
|
| 168 |
+
return commentary
|
| 169 |
+
detail = format_tool_args(tool_name, args)
|
| 170 |
+
if detail and detail != name:
|
| 171 |
+
return f"**{name}:** {detail}"
|
| 172 |
+
return f"**{name}**"
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def format_assistant_message_for_chat(message: dict[str, Any]) -> str:
|
| 176 |
+
"""Render one assistant message for the chat UI (visible text or tool calls; no thinking)."""
|
| 177 |
+
visible, _thinking = extract_assistant_display(message)
|
| 178 |
+
if visible.strip():
|
| 179 |
+
return visible
|
| 180 |
+
|
| 181 |
+
content = message.get("content")
|
| 182 |
+
if not isinstance(content, list):
|
| 183 |
+
return ""
|
| 184 |
+
|
| 185 |
+
return "\n".join(_tool_lines_from_content(content))
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def chat_text_from_assistant_message(message: dict[str, Any] | None) -> str:
|
| 189 |
+
"""Non-thinking chat text from a Pi/Gemini assistant message snapshot."""
|
| 190 |
+
if not message or message.get("role") != "assistant":
|
| 191 |
+
return ""
|
| 192 |
+
return format_assistant_message_for_chat(message)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
_RATE_LIMIT_MARKERS = (
|
| 196 |
+
"429",
|
| 197 |
+
"quota",
|
| 198 |
+
"rate limit",
|
| 199 |
+
"rate-limit",
|
| 200 |
+
"resource_exhausted",
|
| 201 |
+
"too many requests",
|
| 202 |
+
"throttlingexception",
|
| 203 |
+
"throttling",
|
| 204 |
+
"toomanyrequestsexception",
|
| 205 |
+
"servicequotaexceeded",
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def is_rate_limit_error(text: str | None) -> bool:
|
| 210 |
+
"""True when *text* looks like a provider quota or rate-limit failure."""
|
| 211 |
+
if not text:
|
| 212 |
+
return False
|
| 213 |
+
lowered = text.lower()
|
| 214 |
+
return any(marker in lowered for marker in _RATE_LIMIT_MARKERS)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _strip_rpc_payload_for_debug(obj: Any) -> Any:
|
| 218 |
+
"""
|
| 219 |
+
Strip large message content from RPC objects for compact debug logging.
|
| 220 |
+
|
| 221 |
+
Keeps metadata (id, type, command, success) but removes or truncates
|
| 222 |
+
actual message/data payloads.
|
| 223 |
+
"""
|
| 224 |
+
if not isinstance(obj, dict):
|
| 225 |
+
return obj
|
| 226 |
+
|
| 227 |
+
kept_keys = {"type", "id", "command", "success", "error", "stopReason"}
|
| 228 |
+
result = {k: v for k, v in obj.items() if k in kept_keys}
|
| 229 |
+
|
| 230 |
+
# Keep data/result/messages structure without content
|
| 231 |
+
for key in ("data", "result", "messages", "response"):
|
| 232 |
+
if key in obj:
|
| 233 |
+
val = obj[key]
|
| 234 |
+
if isinstance(val, dict):
|
| 235 |
+
result[key] = {k: "..." for k in val.keys()}
|
| 236 |
+
elif isinstance(val, list):
|
| 237 |
+
result[key] = f"[... {len(val)} items]"
|
| 238 |
+
else:
|
| 239 |
+
result[key] = "..."
|
| 240 |
+
|
| 241 |
+
return result
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def last_assistant_turn_error(messages: list[dict[str, Any]]) -> str | None:
|
| 245 |
+
"""Return the latest assistant error in the current user turn, if any."""
|
| 246 |
+
last_user = -1
|
| 247 |
+
for index, message in enumerate(messages):
|
| 248 |
+
if message.get("role") == "user":
|
| 249 |
+
last_user = index
|
| 250 |
+
|
| 251 |
+
turn_messages = messages[last_user + 1 :] if last_user >= 0 else messages
|
| 252 |
+
for message in reversed(turn_messages):
|
| 253 |
+
if message.get("role") != "assistant":
|
| 254 |
+
continue
|
| 255 |
+
error = message.get("errorMessage")
|
| 256 |
+
if error:
|
| 257 |
+
return str(error)
|
| 258 |
+
if message.get("stopReason") == "error":
|
| 259 |
+
visible, _ = extract_assistant_display(message)
|
| 260 |
+
if visible.strip():
|
| 261 |
+
return visible
|
| 262 |
+
return "assistant turn failed"
|
| 263 |
+
return None
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def assistant_text_since_last_user(messages: list[dict[str, Any]]) -> str:
|
| 267 |
+
"""Combine assistant messages from the latest user turn."""
|
| 268 |
+
last_user = -1
|
| 269 |
+
for index, message in enumerate(messages):
|
| 270 |
+
if message.get("role") == "user":
|
| 271 |
+
last_user = index
|
| 272 |
+
|
| 273 |
+
turn_messages = messages[last_user + 1 :] if last_user >= 0 else messages
|
| 274 |
+
parts: list[str] = []
|
| 275 |
+
for message in turn_messages:
|
| 276 |
+
if message.get("role") != "assistant":
|
| 277 |
+
continue
|
| 278 |
+
part = format_assistant_message_for_chat(message)
|
| 279 |
+
if part.strip():
|
| 280 |
+
parts.append(part)
|
| 281 |
+
return "\n\n".join(parts)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def partial_message_from_update(event: dict[str, Any]) -> dict[str, Any] | None:
|
| 285 |
+
delta = event.get("assistantMessageEvent") or {}
|
| 286 |
+
partial = delta.get("partial")
|
| 287 |
+
if isinstance(partial, dict):
|
| 288 |
+
return partial
|
| 289 |
+
message = event.get("message")
|
| 290 |
+
if isinstance(message, dict):
|
| 291 |
+
return message
|
| 292 |
+
return None
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def is_bash_commentary_only(command: str) -> bool:
|
| 296 |
+
"""True when a bash tool call contains only shell comments (no executable lines)."""
|
| 297 |
+
lines = [ln.strip() for ln in command.splitlines() if ln.strip()]
|
| 298 |
+
if not lines:
|
| 299 |
+
return False
|
| 300 |
+
return all(ln.startswith("#") for ln in lines)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def extract_bash_commentary_text(command: str) -> str:
|
| 304 |
+
"""Join non-empty ``#`` comment bodies from a bash command into readable prose."""
|
| 305 |
+
parts: list[str] = []
|
| 306 |
+
for raw in command.splitlines():
|
| 307 |
+
stripped = raw.strip()
|
| 308 |
+
if not stripped.startswith("#"):
|
| 309 |
+
continue
|
| 310 |
+
text = stripped.lstrip("#").strip()
|
| 311 |
+
if text:
|
| 312 |
+
parts.append(text)
|
| 313 |
+
return "\n".join(parts)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def split_bash_commentary_and_command(command: str) -> tuple[str, str]:
|
| 317 |
+
"""Split ``#`` planning lines from executable shell lines."""
|
| 318 |
+
comments: list[str] = []
|
| 319 |
+
commands: list[str] = []
|
| 320 |
+
for raw in command.splitlines():
|
| 321 |
+
stripped = raw.strip()
|
| 322 |
+
if not stripped:
|
| 323 |
+
continue
|
| 324 |
+
if stripped.startswith("#"):
|
| 325 |
+
text = stripped.lstrip("#").strip()
|
| 326 |
+
if text:
|
| 327 |
+
comments.append(text)
|
| 328 |
+
else:
|
| 329 |
+
commands.append(stripped)
|
| 330 |
+
return "\n".join(comments), " β΅ ".join(commands)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def format_tool_args(tool_name: str | None, args: dict[str, Any] | None) -> str:
|
| 334 |
+
if not args:
|
| 335 |
+
return ""
|
| 336 |
+
name = (tool_name or "").lower()
|
| 337 |
+
if name == "bash" and args.get("command"):
|
| 338 |
+
cmd = str(args["command"])
|
| 339 |
+
if is_bash_commentary_only(cmd):
|
| 340 |
+
return extract_bash_commentary_text(cmd)
|
| 341 |
+
_commentary, executable = split_bash_commentary_and_command(cmd)
|
| 342 |
+
if not executable:
|
| 343 |
+
return extract_bash_commentary_text(cmd)
|
| 344 |
+
shown = executable[:240] + ("β¦" if len(executable) > 240 else "")
|
| 345 |
+
return f"`{shown}`"
|
| 346 |
+
if name in {"read", "write", "edit"} and args.get("path"):
|
| 347 |
+
return f"`{args['path']}`"
|
| 348 |
+
compact = json.dumps(args, ensure_ascii=False)
|
| 349 |
+
if len(compact) > 280:
|
| 350 |
+
compact = compact[:277] + "β¦"
|
| 351 |
+
return compact
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
class PiRpcClient:
|
| 355 |
+
"""Drive a long-lived ``pi --mode rpc`` subprocess."""
|
| 356 |
+
|
| 357 |
+
# Extension UI dialog methods block Pi until the client replies; auto-cancel
|
| 358 |
+
# them so a missing UI layer can never wedge the RPC process.
|
| 359 |
+
_EXTENSION_UI_DIALOG_METHODS = frozenset({"select", "confirm", "input", "editor"})
|
| 360 |
+
|
| 361 |
+
def __init__(
|
| 362 |
+
self,
|
| 363 |
+
*,
|
| 364 |
+
cwd: str | None = None,
|
| 365 |
+
env: dict[str, str] | None = None,
|
| 366 |
+
pi_args: list[str] | None = None,
|
| 367 |
+
) -> None:
|
| 368 |
+
self._cwd = cwd
|
| 369 |
+
self._env = env
|
| 370 |
+
self._pi_args = pi_args or []
|
| 371 |
+
self._proc: subprocess.Popen[str] | None = None
|
| 372 |
+
self._write_lock = threading.Lock()
|
| 373 |
+
self._abort_requested = False
|
| 374 |
+
self._prompt_stream_depth = 0
|
| 375 |
+
self._pending_follow_ups = 0
|
| 376 |
+
self._pending_ui_history: list[dict[str, Any]] = []
|
| 377 |
+
# Single stdout reader thread demultiplexes the JSONL stream: command
|
| 378 |
+
# responses go to per-id slots, agent events go to ``_events``. This lets
|
| 379 |
+
# any thread (e.g. post-task logging) call the client safely while a
|
| 380 |
+
# prompt stream is active.
|
| 381 |
+
self._reader_thread: threading.Thread | None = None
|
| 382 |
+
self._stderr_thread: threading.Thread | None = None
|
| 383 |
+
self._events: queue.Queue[Any] = queue.Queue()
|
| 384 |
+
self._pending_lock = threading.Lock()
|
| 385 |
+
self._pending_responses: dict[str, queue.Queue[Any]] = {}
|
| 386 |
+
self._stderr_buffer: deque[str] = deque(maxlen=200)
|
| 387 |
+
self._closing = False
|
| 388 |
+
|
| 389 |
+
@property
|
| 390 |
+
def running(self) -> bool:
|
| 391 |
+
return self._proc is not None and self._proc.poll() is None
|
| 392 |
+
|
| 393 |
+
@property
|
| 394 |
+
def prompt_stream_active(self) -> bool:
|
| 395 |
+
"""True while :meth:`prompt_events` is consuming the RPC event stream."""
|
| 396 |
+
return self._prompt_stream_depth > 0
|
| 397 |
+
|
| 398 |
+
def start(self) -> None:
|
| 399 |
+
if self.running:
|
| 400 |
+
return
|
| 401 |
+
command = [resolve_pi_executable(), "--mode", "rpc", *self._pi_args]
|
| 402 |
+
self._closing = False
|
| 403 |
+
self._abort_requested = False
|
| 404 |
+
proc = subprocess.Popen(
|
| 405 |
+
command,
|
| 406 |
+
stdin=subprocess.PIPE,
|
| 407 |
+
stdout=subprocess.PIPE,
|
| 408 |
+
stderr=subprocess.PIPE,
|
| 409 |
+
encoding=_PI_SUBPROCESS_ENCODING,
|
| 410 |
+
errors=_PI_SUBPROCESS_ENCODING_ERRORS,
|
| 411 |
+
bufsize=1,
|
| 412 |
+
cwd=self._cwd,
|
| 413 |
+
env=self._env,
|
| 414 |
+
)
|
| 415 |
+
self._proc = proc
|
| 416 |
+
# Fresh demux state for this process.
|
| 417 |
+
self._events = queue.Queue()
|
| 418 |
+
with self._pending_lock:
|
| 419 |
+
self._pending_responses = {}
|
| 420 |
+
self._stderr_buffer = deque(maxlen=200)
|
| 421 |
+
self._reader_thread = threading.Thread(
|
| 422 |
+
target=self._reader_loop,
|
| 423 |
+
args=(proc,),
|
| 424 |
+
name="pi-rpc-stdout",
|
| 425 |
+
daemon=True,
|
| 426 |
+
)
|
| 427 |
+
self._reader_thread.start()
|
| 428 |
+
if proc.stderr is not None:
|
| 429 |
+
self._stderr_thread = threading.Thread(
|
| 430 |
+
target=self._stderr_loop,
|
| 431 |
+
args=(proc,),
|
| 432 |
+
name="pi-rpc-stderr",
|
| 433 |
+
daemon=True,
|
| 434 |
+
)
|
| 435 |
+
self._stderr_thread.start()
|
| 436 |
+
|
| 437 |
+
def close(self) -> None:
|
| 438 |
+
if not self._proc:
|
| 439 |
+
return
|
| 440 |
+
self._closing = True
|
| 441 |
+
proc = self._proc
|
| 442 |
+
if proc.poll() is None:
|
| 443 |
+
try:
|
| 444 |
+
self.abort()
|
| 445 |
+
except Exception:
|
| 446 |
+
pass
|
| 447 |
+
proc.terminate()
|
| 448 |
+
try:
|
| 449 |
+
proc.wait(timeout=5)
|
| 450 |
+
except subprocess.TimeoutExpired:
|
| 451 |
+
proc.kill()
|
| 452 |
+
# Process exit makes ``readline`` return EOF; the reader thread then
|
| 453 |
+
# notifies waiters. Nudge waiters here too in case the threads are slow.
|
| 454 |
+
self._notify_process_exit()
|
| 455 |
+
for thread in (self._reader_thread, self._stderr_thread):
|
| 456 |
+
if (
|
| 457 |
+
thread is not None
|
| 458 |
+
and thread.is_alive()
|
| 459 |
+
and thread is not threading.current_thread()
|
| 460 |
+
):
|
| 461 |
+
thread.join(timeout=2)
|
| 462 |
+
self._reader_thread = None
|
| 463 |
+
self._stderr_thread = None
|
| 464 |
+
self._proc = None
|
| 465 |
+
|
| 466 |
+
def _ensure_running(self) -> subprocess.Popen[str]:
|
| 467 |
+
if not self.running:
|
| 468 |
+
self.start()
|
| 469 |
+
assert self._proc is not None
|
| 470 |
+
return self._proc
|
| 471 |
+
|
| 472 |
+
def _recent_stderr(self) -> str:
|
| 473 |
+
return "\n".join(self._stderr_buffer)
|
| 474 |
+
|
| 475 |
+
def _process_exit_error(self) -> PiRpcError:
|
| 476 |
+
code = self._proc.poll() if self._proc else None
|
| 477 |
+
err = self._recent_stderr()
|
| 478 |
+
return PiRpcError(
|
| 479 |
+
f"Pi RPC process exited (code={code})."
|
| 480 |
+
+ (f" stderr: {err[:500]}" if err else "")
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
def _notify_process_exit(self) -> None:
|
| 484 |
+
"""Unblock every pending response slot and the events queue on exit."""
|
| 485 |
+
with self._pending_lock:
|
| 486 |
+
pending = list(self._pending_responses.values())
|
| 487 |
+
self._pending_responses.clear()
|
| 488 |
+
for slot in pending:
|
| 489 |
+
try:
|
| 490 |
+
slot.put_nowait(_PI_PROCESS_EXIT)
|
| 491 |
+
except queue.Full:
|
| 492 |
+
pass
|
| 493 |
+
try:
|
| 494 |
+
self._events.put_nowait(_PI_PROCESS_EXIT)
|
| 495 |
+
except queue.Full:
|
| 496 |
+
pass
|
| 497 |
+
|
| 498 |
+
def _stderr_loop(self, proc: subprocess.Popen[str]) -> None:
|
| 499 |
+
"""Continuously drain stderr into a bounded buffer (prevents pipe deadlock)."""
|
| 500 |
+
stream = proc.stderr
|
| 501 |
+
if stream is None:
|
| 502 |
+
return
|
| 503 |
+
try:
|
| 504 |
+
for line in stream:
|
| 505 |
+
self._stderr_buffer.append(line.rstrip("\r\n"))
|
| 506 |
+
except (ValueError, OSError):
|
| 507 |
+
pass
|
| 508 |
+
|
| 509 |
+
def _reader_loop(self, proc: subprocess.Popen[str]) -> None:
|
| 510 |
+
"""Read every stdout line and route responses vs. agent events."""
|
| 511 |
+
stream = proc.stdout
|
| 512 |
+
if stream is None:
|
| 513 |
+
self._notify_process_exit()
|
| 514 |
+
return
|
| 515 |
+
try:
|
| 516 |
+
while True:
|
| 517 |
+
line = stream.readline()
|
| 518 |
+
if not line:
|
| 519 |
+
break
|
| 520 |
+
line = line.rstrip("\r\n")
|
| 521 |
+
if not line:
|
| 522 |
+
continue
|
| 523 |
+
try:
|
| 524 |
+
message = json.loads(line)
|
| 525 |
+
except json.JSONDecodeError:
|
| 526 |
+
continue
|
| 527 |
+
self._dispatch_message(message)
|
| 528 |
+
except (ValueError, OSError):
|
| 529 |
+
pass
|
| 530 |
+
finally:
|
| 531 |
+
self._notify_process_exit()
|
| 532 |
+
|
| 533 |
+
def _dispatch_message(self, message: Any) -> None:
|
| 534 |
+
if not isinstance(message, dict):
|
| 535 |
+
return
|
| 536 |
+
if os.environ.get("AGENT_RPC_DEBUG", "").strip() == "1":
|
| 537 |
+
try:
|
| 538 |
+
stripped = _strip_rpc_payload_for_debug(message)
|
| 539 |
+
sys.stderr.write(
|
| 540 |
+
"Pi RPC recv: " + json.dumps(stripped, ensure_ascii=False) + "\n"
|
| 541 |
+
)
|
| 542 |
+
sys.stderr.flush()
|
| 543 |
+
except Exception:
|
| 544 |
+
pass
|
| 545 |
+
msg_type = message.get("type")
|
| 546 |
+
if msg_type == "response":
|
| 547 |
+
req_id = message.get("id")
|
| 548 |
+
slot: queue.Queue[Any] | None = None
|
| 549 |
+
if req_id is not None:
|
| 550 |
+
with self._pending_lock:
|
| 551 |
+
slot = self._pending_responses.pop(str(req_id), None)
|
| 552 |
+
if slot is not None:
|
| 553 |
+
try:
|
| 554 |
+
slot.put_nowait(message)
|
| 555 |
+
except queue.Full:
|
| 556 |
+
pass
|
| 557 |
+
return
|
| 558 |
+
if msg_type == "extension_ui_request":
|
| 559 |
+
self._auto_reply_extension_ui(message)
|
| 560 |
+
return
|
| 561 |
+
# Agent event β consumed by the active ``prompt_events`` stream.
|
| 562 |
+
self._events.put(message)
|
| 563 |
+
|
| 564 |
+
def _auto_reply_extension_ui(self, message: dict[str, Any]) -> None:
|
| 565 |
+
method = message.get("method")
|
| 566 |
+
req_id = message.get("id")
|
| 567 |
+
if req_id is None or method not in self._EXTENSION_UI_DIALOG_METHODS:
|
| 568 |
+
return
|
| 569 |
+
try:
|
| 570 |
+
self._write_command(
|
| 571 |
+
{"type": "extension_ui_response", "id": req_id, "cancelled": True}
|
| 572 |
+
)
|
| 573 |
+
except (OSError, PiRpcError):
|
| 574 |
+
pass
|
| 575 |
+
|
| 576 |
+
def _write_command(self, command: dict[str, Any]) -> None:
|
| 577 |
+
proc = self._ensure_running()
|
| 578 |
+
assert proc.stdin is not None
|
| 579 |
+
if os.environ.get("AGENT_RPC_DEBUG", "").strip() == "1":
|
| 580 |
+
try:
|
| 581 |
+
stripped = _strip_rpc_payload_for_debug(command)
|
| 582 |
+
sys.stderr.write(
|
| 583 |
+
"Pi RPC send: " + json.dumps(stripped, ensure_ascii=False) + "\n"
|
| 584 |
+
)
|
| 585 |
+
sys.stderr.flush()
|
| 586 |
+
except Exception:
|
| 587 |
+
pass
|
| 588 |
+
with self._write_lock:
|
| 589 |
+
proc.stdin.write(json.dumps(command) + "\n")
|
| 590 |
+
proc.stdin.flush()
|
| 591 |
+
|
| 592 |
+
def _send_command(
|
| 593 |
+
self,
|
| 594 |
+
command: dict[str, Any],
|
| 595 |
+
*,
|
| 596 |
+
wait_response: bool = True,
|
| 597 |
+
) -> dict[str, Any] | None:
|
| 598 |
+
req_id = str(command.setdefault("id", str(uuid.uuid4())))
|
| 599 |
+
if not wait_response:
|
| 600 |
+
self._write_command(command)
|
| 601 |
+
return None
|
| 602 |
+
slot: queue.Queue[Any] = queue.Queue(maxsize=1)
|
| 603 |
+
with self._pending_lock:
|
| 604 |
+
self._pending_responses[req_id] = slot
|
| 605 |
+
try:
|
| 606 |
+
self._write_command(command)
|
| 607 |
+
except Exception:
|
| 608 |
+
with self._pending_lock:
|
| 609 |
+
self._pending_responses.pop(req_id, None)
|
| 610 |
+
raise
|
| 611 |
+
result = slot.get()
|
| 612 |
+
if result is _PI_PROCESS_EXIT:
|
| 613 |
+
raise self._process_exit_error()
|
| 614 |
+
if not result.get("success", False):
|
| 615 |
+
error = result.get("error") or result.get("message") or "command failed"
|
| 616 |
+
raise PiRpcError(str(error))
|
| 617 |
+
return result
|
| 618 |
+
|
| 619 |
+
def abort(self) -> None:
|
| 620 |
+
"""Request abort without reading stdout (the active stream consumer drains events)."""
|
| 621 |
+
if not self.running:
|
| 622 |
+
return
|
| 623 |
+
self._abort_requested = True
|
| 624 |
+
try:
|
| 625 |
+
self._send_command({"type": "abort"}, wait_response=False)
|
| 626 |
+
except OSError:
|
| 627 |
+
pass
|
| 628 |
+
|
| 629 |
+
def stage_ui_chat_notice(self, label: str, message: str) -> None:
|
| 630 |
+
"""Stage user/assistant chat rows for the active prompt stream to merge on yield."""
|
| 631 |
+
text = message.strip()
|
| 632 |
+
if not text:
|
| 633 |
+
return
|
| 634 |
+
self._pending_ui_history.append(
|
| 635 |
+
{"role": "user", "content": f"_**{label}:**_ {text}"}
|
| 636 |
+
)
|
| 637 |
+
self._pending_ui_history.append({"role": "assistant", "content": ""})
|
| 638 |
+
|
| 639 |
+
def drain_pending_ui_history(self) -> list[dict[str, Any]]:
|
| 640 |
+
"""Return and clear UI chat rows staged by :meth:`stage_ui_chat_notice`."""
|
| 641 |
+
pending = self._pending_ui_history[:]
|
| 642 |
+
self._pending_ui_history.clear()
|
| 643 |
+
return pending
|
| 644 |
+
|
| 645 |
+
def steer(self, message: str) -> None:
|
| 646 |
+
"""Queue a steering message (delivered after the current tool step completes)."""
|
| 647 |
+
if not message.strip():
|
| 648 |
+
return
|
| 649 |
+
self._send_command(
|
| 650 |
+
{"type": "steer", "message": message},
|
| 651 |
+
wait_response=False,
|
| 652 |
+
)
|
| 653 |
+
|
| 654 |
+
def follow_up(self, message: str) -> None:
|
| 655 |
+
"""Queue a follow-up message for when the agent stops."""
|
| 656 |
+
if not message.strip():
|
| 657 |
+
return
|
| 658 |
+
self._pending_follow_ups += 1
|
| 659 |
+
self._send_command(
|
| 660 |
+
{"type": "follow_up", "message": message},
|
| 661 |
+
wait_response=False,
|
| 662 |
+
)
|
| 663 |
+
|
| 664 |
+
@property
|
| 665 |
+
def abort_requested(self) -> bool:
|
| 666 |
+
return self._abort_requested
|
| 667 |
+
|
| 668 |
+
def clear_abort(self) -> None:
|
| 669 |
+
self._abort_requested = False
|
| 670 |
+
|
| 671 |
+
def new_session(self) -> None:
|
| 672 |
+
self._send_command({"type": "new_session"})
|
| 673 |
+
|
| 674 |
+
def get_state(self) -> dict[str, Any]:
|
| 675 |
+
response = self._send_command({"type": "get_state"})
|
| 676 |
+
data = response.get("data") if response else {}
|
| 677 |
+
return data if isinstance(data, dict) else {}
|
| 678 |
+
|
| 679 |
+
def get_messages(self) -> list[dict[str, Any]]:
|
| 680 |
+
response = self._send_command({"type": "get_messages"})
|
| 681 |
+
data = response.get("data") if response else {}
|
| 682 |
+
messages = data.get("messages") if isinstance(data, dict) else []
|
| 683 |
+
return messages if isinstance(messages, list) else []
|
| 684 |
+
|
| 685 |
+
def get_session_stats(self) -> dict[str, Any]:
|
| 686 |
+
"""Token usage and cost totals for the active session (Pi RPC ``get_session_stats``)."""
|
| 687 |
+
response = self._send_command({"type": "get_session_stats"})
|
| 688 |
+
data = response.get("data") if response else {}
|
| 689 |
+
return data if isinstance(data, dict) else {}
|
| 690 |
+
|
| 691 |
+
def set_model(self, provider: str, model_id: str) -> dict[str, Any]:
|
| 692 |
+
response = self._send_command(
|
| 693 |
+
{
|
| 694 |
+
"type": "set_model",
|
| 695 |
+
"provider": provider,
|
| 696 |
+
"modelId": model_id,
|
| 697 |
+
}
|
| 698 |
+
)
|
| 699 |
+
data = response.get("data") if response else {}
|
| 700 |
+
return data if isinstance(data, dict) else {}
|
| 701 |
+
|
| 702 |
+
def get_available_models(self) -> list[dict[str, Any]]:
|
| 703 |
+
response = self._send_command({"type": "get_available_models"})
|
| 704 |
+
data = response.get("data") if response else {}
|
| 705 |
+
models = data.get("models") if isinstance(data, dict) else []
|
| 706 |
+
return models if isinstance(models, list) else []
|
| 707 |
+
|
| 708 |
+
def restart(self) -> None:
|
| 709 |
+
self.close()
|
| 710 |
+
self.start()
|
| 711 |
+
|
| 712 |
+
def prompt_events(self, message: str) -> Iterator[PiStreamEvent]:
|
| 713 |
+
"""Send a user message and yield structured events until ``agent_end``."""
|
| 714 |
+
self._prompt_stream_depth += 1
|
| 715 |
+
try:
|
| 716 |
+
yield from self._prompt_events_impl(message)
|
| 717 |
+
finally:
|
| 718 |
+
self._prompt_stream_depth = max(0, self._prompt_stream_depth - 1)
|
| 719 |
+
|
| 720 |
+
def _drain_events(self) -> None:
|
| 721 |
+
"""Discard stale events left over from a prior stream (single active prompt)."""
|
| 722 |
+
while True:
|
| 723 |
+
try:
|
| 724 |
+
item = self._events.get_nowait()
|
| 725 |
+
except queue.Empty:
|
| 726 |
+
return
|
| 727 |
+
if item is _PI_PROCESS_EXIT:
|
| 728 |
+
# Preserve the exit signal for the consumer to observe.
|
| 729 |
+
try:
|
| 730 |
+
self._events.put_nowait(_PI_PROCESS_EXIT)
|
| 731 |
+
except queue.Full:
|
| 732 |
+
pass
|
| 733 |
+
return
|
| 734 |
+
|
| 735 |
+
def _prompt_events_impl(self, message: str) -> Iterator[PiStreamEvent]:
|
| 736 |
+
self.clear_abort()
|
| 737 |
+
self._drain_events()
|
| 738 |
+
try:
|
| 739 |
+
self._send_command({"type": "prompt", "message": message})
|
| 740 |
+
except PiRpcError as exc:
|
| 741 |
+
yield PiStreamEvent(kind="error", text=str(exc), is_error=True)
|
| 742 |
+
return
|
| 743 |
+
|
| 744 |
+
yield from self._iter_agent_events()
|
| 745 |
+
|
| 746 |
+
def _iter_agent_events(self) -> Iterator[PiStreamEvent]:
|
| 747 |
+
while True:
|
| 748 |
+
event = self._events.get()
|
| 749 |
+
if event is _PI_PROCESS_EXIT:
|
| 750 |
+
raise self._process_exit_error()
|
| 751 |
+
event_type = event.get("type")
|
| 752 |
+
|
| 753 |
+
if event_type == "agent_start":
|
| 754 |
+
yield PiStreamEvent(kind="status", text="Agent startedβ¦")
|
| 755 |
+
|
| 756 |
+
elif event_type == "turn_start":
|
| 757 |
+
yield PiStreamEvent(kind="status", text="Turn started.")
|
| 758 |
+
|
| 759 |
+
elif event_type == "turn_end":
|
| 760 |
+
yield PiStreamEvent(kind="turn_end", text="Turn finished.")
|
| 761 |
+
|
| 762 |
+
elif event_type == "message_update":
|
| 763 |
+
yield from self._parse_message_update(event)
|
| 764 |
+
|
| 765 |
+
elif event_type == "tool_execution_start":
|
| 766 |
+
tool_name = event.get("toolName")
|
| 767 |
+
tool_args = (
|
| 768 |
+
event.get("args") if isinstance(event.get("args"), dict) else {}
|
| 769 |
+
)
|
| 770 |
+
yield PiStreamEvent(
|
| 771 |
+
kind="tool_start",
|
| 772 |
+
tool_name=str(tool_name) if tool_name else "tool",
|
| 773 |
+
tool_call_id=event.get("toolCallId"),
|
| 774 |
+
tool_args=tool_args,
|
| 775 |
+
text=format_tool_args(
|
| 776 |
+
str(tool_name) if tool_name else None,
|
| 777 |
+
tool_args,
|
| 778 |
+
),
|
| 779 |
+
)
|
| 780 |
+
|
| 781 |
+
elif event_type == "tool_execution_update":
|
| 782 |
+
output = extract_tool_text(event)
|
| 783 |
+
yield PiStreamEvent(
|
| 784 |
+
kind="tool_update",
|
| 785 |
+
tool_name=event.get("toolName"),
|
| 786 |
+
tool_call_id=event.get("toolCallId"),
|
| 787 |
+
tool_output=output,
|
| 788 |
+
)
|
| 789 |
+
|
| 790 |
+
elif event_type == "tool_execution_end":
|
| 791 |
+
result = (
|
| 792 |
+
event.get("result") if isinstance(event.get("result"), dict) else {}
|
| 793 |
+
)
|
| 794 |
+
output = extract_tool_text(result)
|
| 795 |
+
yield PiStreamEvent(
|
| 796 |
+
kind="tool_end",
|
| 797 |
+
tool_name=event.get("toolName"),
|
| 798 |
+
tool_call_id=event.get("toolCallId"),
|
| 799 |
+
tool_output=output,
|
| 800 |
+
is_error=bool(event.get("isError")),
|
| 801 |
+
)
|
| 802 |
+
|
| 803 |
+
elif event_type == "queue_update":
|
| 804 |
+
steering = event.get("steering") or []
|
| 805 |
+
follow_up = event.get("followUp") or []
|
| 806 |
+
if steering or follow_up:
|
| 807 |
+
yield PiStreamEvent(
|
| 808 |
+
kind="queue_update",
|
| 809 |
+
meta={"steering": steering, "follow_up": follow_up},
|
| 810 |
+
)
|
| 811 |
+
|
| 812 |
+
elif event_type == "compaction_start":
|
| 813 |
+
reason = event.get("reason") or "context limit"
|
| 814 |
+
yield PiStreamEvent(
|
| 815 |
+
kind="compaction_start",
|
| 816 |
+
text=(
|
| 817 |
+
"Session context is being summarised to stay within the model "
|
| 818 |
+
f"limit ({reason}). Older messages and tool output are compressed; "
|
| 819 |
+
"recent turns are kept."
|
| 820 |
+
),
|
| 821 |
+
meta={"reason": reason, "phase": "start"},
|
| 822 |
+
)
|
| 823 |
+
|
| 824 |
+
elif event_type == "compaction_end":
|
| 825 |
+
if event.get("aborted"):
|
| 826 |
+
text = "Context compaction was aborted."
|
| 827 |
+
yield PiStreamEvent(
|
| 828 |
+
kind="compaction_end",
|
| 829 |
+
text=text,
|
| 830 |
+
meta={"phase": "end", "aborted": True},
|
| 831 |
+
)
|
| 832 |
+
elif event.get("errorMessage"):
|
| 833 |
+
text = f"Context compaction failed: {event['errorMessage']}"
|
| 834 |
+
yield PiStreamEvent(kind="error", text=text, is_error=True)
|
| 835 |
+
continue
|
| 836 |
+
elif event.get("willRetry"):
|
| 837 |
+
text = (
|
| 838 |
+
"Context compaction finished β retrying the prompt with a "
|
| 839 |
+
"shorter history."
|
| 840 |
+
)
|
| 841 |
+
yield PiStreamEvent(
|
| 842 |
+
kind="compaction_end",
|
| 843 |
+
text=text,
|
| 844 |
+
meta={"phase": "end", "willRetry": True},
|
| 845 |
+
)
|
| 846 |
+
else:
|
| 847 |
+
tokens = (event.get("result") or {}).get("tokensBefore")
|
| 848 |
+
if isinstance(tokens, int):
|
| 849 |
+
text = (
|
| 850 |
+
f"Context compaction finished ({tokens:,} tokens before "
|
| 851 |
+
"summarisation). The agent continues with a shorter context."
|
| 852 |
+
)
|
| 853 |
+
else:
|
| 854 |
+
text = (
|
| 855 |
+
"Context compaction finished. The agent continues with a "
|
| 856 |
+
"shorter context."
|
| 857 |
+
)
|
| 858 |
+
yield PiStreamEvent(
|
| 859 |
+
kind="compaction_end",
|
| 860 |
+
text=text,
|
| 861 |
+
meta={"phase": "end", "tokensBefore": tokens},
|
| 862 |
+
)
|
| 863 |
+
|
| 864 |
+
elif event_type == "auto_retry_start":
|
| 865 |
+
attempt = event.get("attempt")
|
| 866 |
+
max_attempts = event.get("maxAttempts")
|
| 867 |
+
delay_ms = event.get("delayMs")
|
| 868 |
+
msg = event.get("errorMessage") or "transient error"
|
| 869 |
+
yield PiStreamEvent(
|
| 870 |
+
kind="status",
|
| 871 |
+
text=(
|
| 872 |
+
f"Auto-retry {attempt}/{max_attempts} in {delay_ms}ms "
|
| 873 |
+
f"({str(msg)[:120]})"
|
| 874 |
+
),
|
| 875 |
+
meta=event,
|
| 876 |
+
)
|
| 877 |
+
|
| 878 |
+
elif event_type == "auto_retry_end":
|
| 879 |
+
if event.get("success"):
|
| 880 |
+
yield PiStreamEvent(
|
| 881 |
+
kind="status",
|
| 882 |
+
text=f"Auto-retry succeeded on attempt {event.get('attempt')}.",
|
| 883 |
+
)
|
| 884 |
+
else:
|
| 885 |
+
yield PiStreamEvent(
|
| 886 |
+
kind="error",
|
| 887 |
+
text=f"Auto-retry failed: {event.get('finalError', 'unknown error')}",
|
| 888 |
+
is_error=True,
|
| 889 |
+
)
|
| 890 |
+
|
| 891 |
+
elif event_type == "extension_error":
|
| 892 |
+
yield PiStreamEvent(
|
| 893 |
+
kind="error",
|
| 894 |
+
text=str(event.get("error") or "extension error"),
|
| 895 |
+
is_error=True,
|
| 896 |
+
)
|
| 897 |
+
|
| 898 |
+
elif event_type == "agent_end":
|
| 899 |
+
# Pi delivers queued ``follow_up`` messages after ``agent_end`` and
|
| 900 |
+
# continues streaming; do not stop the stdout consumer until they run.
|
| 901 |
+
if self._pending_follow_ups > 0:
|
| 902 |
+
self._pending_follow_ups -= 1
|
| 903 |
+
yield PiStreamEvent(
|
| 904 |
+
kind="status",
|
| 905 |
+
text="Follow-up queued β continuingβ¦",
|
| 906 |
+
)
|
| 907 |
+
continue
|
| 908 |
+
aborted = self._abort_requested
|
| 909 |
+
self.clear_abort()
|
| 910 |
+
yield PiStreamEvent(
|
| 911 |
+
kind="done",
|
| 912 |
+
text="Agent aborted." if aborted else "Agent finished.",
|
| 913 |
+
)
|
| 914 |
+
return
|
| 915 |
+
|
| 916 |
+
def _parse_message_update(self, event: dict[str, Any]) -> Iterator[PiStreamEvent]:
|
| 917 |
+
delta = event.get("assistantMessageEvent") or {}
|
| 918 |
+
delta_type = delta.get("type")
|
| 919 |
+
partial = partial_message_from_update(event)
|
| 920 |
+
if partial is not None:
|
| 921 |
+
visible, thinking = extract_assistant_display(partial)
|
| 922 |
+
if visible.strip():
|
| 923 |
+
yield PiStreamEvent(kind="text_snapshot", text=visible)
|
| 924 |
+
elif chat_text := chat_text_from_assistant_message(partial):
|
| 925 |
+
yield PiStreamEvent(kind="text_snapshot", text=chat_text)
|
| 926 |
+
if thinking.strip():
|
| 927 |
+
yield PiStreamEvent(kind="thinking_snapshot", text=thinking)
|
| 928 |
+
|
| 929 |
+
if delta_type == "text_delta":
|
| 930 |
+
chunk = delta.get("delta") or ""
|
| 931 |
+
if chunk:
|
| 932 |
+
yield PiStreamEvent(kind="text_delta", text=chunk)
|
| 933 |
+
|
| 934 |
+
elif delta_type == "thinking_delta":
|
| 935 |
+
chunk = delta.get("delta") or ""
|
| 936 |
+
if chunk:
|
| 937 |
+
yield PiStreamEvent(kind="thinking_delta", text=chunk)
|
| 938 |
+
|
| 939 |
+
elif delta_type == "toolcall_start":
|
| 940 |
+
tool_call = delta.get("toolCall") or {}
|
| 941 |
+
tool_name = tool_call.get("name") or delta.get("toolName") or "tool"
|
| 942 |
+
tool_args = tool_call.get("arguments")
|
| 943 |
+
if isinstance(tool_args, str):
|
| 944 |
+
try:
|
| 945 |
+
tool_args = json.loads(tool_args)
|
| 946 |
+
except json.JSONDecodeError:
|
| 947 |
+
tool_args = {"raw": tool_args}
|
| 948 |
+
if not isinstance(tool_args, dict):
|
| 949 |
+
tool_args = {}
|
| 950 |
+
chat_line = format_tool_chat_line(str(tool_name), tool_args)
|
| 951 |
+
yield PiStreamEvent(kind="text_snapshot", text=chat_line)
|
| 952 |
+
|
| 953 |
+
elif delta_type == "error":
|
| 954 |
+
yield PiStreamEvent(
|
| 955 |
+
kind="error",
|
| 956 |
+
text=str(
|
| 957 |
+
delta.get("message") or delta.get("error") or "generation error"
|
| 958 |
+
),
|
| 959 |
+
is_error=True,
|
| 960 |
+
)
|
| 961 |
+
|
| 962 |
+
def prompt_stream(
|
| 963 |
+
self, message: str, *, show_tool_status: bool = True
|
| 964 |
+
) -> Iterator[str]:
|
| 965 |
+
"""Backward-compatible text stream (assistant visible text + optional tool status)."""
|
| 966 |
+
for event in self.prompt_events(message):
|
| 967 |
+
if event.kind == "text_delta":
|
| 968 |
+
yield event.text
|
| 969 |
+
elif show_tool_status and event.kind == "tool_start":
|
| 970 |
+
yield f"\n\n_[Running {event.tool_name}β¦]_\n"
|
| 971 |
+
elif event.kind == "error":
|
| 972 |
+
yield f"\n\n**Error:** {event.text}\n"
|
| 973 |
+
|
| 974 |
+
|
| 975 |
+
def start_pi_prompt_event_worker(
|
| 976 |
+
client: PiRpcClient,
|
| 977 |
+
event_queue: queue.Queue[Any],
|
| 978 |
+
prompt: str,
|
| 979 |
+
) -> None:
|
| 980 |
+
"""Run ``client.prompt_events`` on a background thread, feeding *event_queue*."""
|
| 981 |
+
|
| 982 |
+
def _worker() -> None:
|
| 983 |
+
try:
|
| 984 |
+
for event in client.prompt_events(prompt):
|
| 985 |
+
event_queue.put(event)
|
| 986 |
+
except Exception as exc:
|
| 987 |
+
event_queue.put(PiStreamEvent(kind="error", text=str(exc), is_error=True))
|
| 988 |
+
finally:
|
| 989 |
+
event_queue.put(None)
|
| 990 |
+
|
| 991 |
+
threading.Thread(target=_worker, daemon=True).start()
|
| 992 |
+
|
| 993 |
+
|
| 994 |
+
def default_client(session_hash: str | None = None) -> PiRpcClient:
|
| 995 |
+
from pi_agent_config import configure_aws_credentials, configure_pi_coding_agent_env
|
| 996 |
+
from pi_workspace_skills import ensure_workspace_skills, pi_rpc_args, pi_rpc_cwd
|
| 997 |
+
|
| 998 |
+
configure_aws_credentials()
|
| 999 |
+
configure_pi_coding_agent_env()
|
| 1000 |
+
ensure_workspace_skills()
|
| 1001 |
+
env = os.environ.copy()
|
| 1002 |
+
env.setdefault("HOME", os.path.expanduser("~"))
|
| 1003 |
+
env.setdefault("PYTHONUTF8", "1")
|
| 1004 |
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
| 1005 |
+
from session_workspace import workspace_base_dir
|
| 1006 |
+
|
| 1007 |
+
env.setdefault("AGENT_WORKSPACE_DIR", str(workspace_base_dir()))
|
| 1008 |
+
if not env.get("GEMINI_API_KEY") and env.get("GOOGLE_API_KEY"):
|
| 1009 |
+
env["GEMINI_API_KEY"] = env["GOOGLE_API_KEY"]
|
| 1010 |
+
if not env.get("HF_TOKEN") and env.get("DOC_REDACTION_HF_TOKEN"):
|
| 1011 |
+
env["HF_TOKEN"] = env["DOC_REDACTION_HF_TOKEN"]
|
| 1012 |
+
return PiRpcClient(
|
| 1013 |
+
cwd=pi_rpc_cwd(session_hash),
|
| 1014 |
+
env=env,
|
| 1015 |
+
pi_args=pi_rpc_args(),
|
| 1016 |
+
)
|
agent-redact/pi/pi_session_usage.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Summarize Pi agent LLM token usage for usage-log CSV rows."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from pi_rpc_client import PiRpcClient, PiRpcError # noqa: E402
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
from agent_runtime import AgentRuntime
|
| 14 |
+
except ImportError:
|
| 15 |
+
AgentRuntime = PiRpcClient # type: ignore[misc,assignment]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class TokenUsageTotals:
|
| 20 |
+
"""Pi session usage (see Pi session-format ``Usage``)."""
|
| 21 |
+
|
| 22 |
+
input: int = 0
|
| 23 |
+
output: int = 0
|
| 24 |
+
cache_read: int = 0
|
| 25 |
+
cache_write: int = 0
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def llm_input_tokens(self) -> int:
|
| 29 |
+
"""Input-side tokens for the main-app usage log (input + cache)."""
|
| 30 |
+
return self.input + self.cache_read + self.cache_write
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def llm_output_tokens(self) -> int:
|
| 34 |
+
return self.output
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _int_field(raw: Any) -> int:
|
| 38 |
+
try:
|
| 39 |
+
return max(0, int(raw or 0))
|
| 40 |
+
except (TypeError, ValueError):
|
| 41 |
+
return 0
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def totals_from_usage_dict(usage: dict[str, Any] | None) -> TokenUsageTotals:
|
| 45 |
+
if not usage:
|
| 46 |
+
return TokenUsageTotals()
|
| 47 |
+
return TokenUsageTotals(
|
| 48 |
+
input=_int_field(usage.get("input")),
|
| 49 |
+
output=_int_field(usage.get("output")),
|
| 50 |
+
cache_read=_int_field(usage.get("cacheRead")),
|
| 51 |
+
cache_write=_int_field(usage.get("cacheWrite")),
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def totals_from_stats_payload(data: dict[str, Any] | None) -> TokenUsageTotals:
|
| 56 |
+
if not data:
|
| 57 |
+
return TokenUsageTotals()
|
| 58 |
+
tokens = data.get("tokens")
|
| 59 |
+
if isinstance(tokens, dict):
|
| 60 |
+
return totals_from_usage_dict(tokens)
|
| 61 |
+
return TokenUsageTotals()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def subtract_usage(
|
| 65 |
+
after: TokenUsageTotals, before: TokenUsageTotals
|
| 66 |
+
) -> TokenUsageTotals:
|
| 67 |
+
return TokenUsageTotals(
|
| 68 |
+
input=max(0, after.input - before.input),
|
| 69 |
+
output=max(0, after.output - before.output),
|
| 70 |
+
cache_read=max(0, after.cache_read - before.cache_read),
|
| 71 |
+
cache_write=max(0, after.cache_write - before.cache_write),
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def add_usage(left: TokenUsageTotals, right: TokenUsageTotals) -> TokenUsageTotals:
|
| 76 |
+
return TokenUsageTotals(
|
| 77 |
+
input=left.input + right.input,
|
| 78 |
+
output=left.output + right.output,
|
| 79 |
+
cache_read=left.cache_read + right.cache_read,
|
| 80 |
+
cache_write=left.cache_write + right.cache_write,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def sum_usage_from_messages(
|
| 85 |
+
messages: list[dict[str, Any]],
|
| 86 |
+
*,
|
| 87 |
+
since_last_user: bool = False,
|
| 88 |
+
) -> TokenUsageTotals:
|
| 89 |
+
"""Sum ``usage`` on assistant messages (optional: only after the last user turn)."""
|
| 90 |
+
last_user = -1
|
| 91 |
+
if since_last_user:
|
| 92 |
+
for index, message in enumerate(messages):
|
| 93 |
+
if message.get("role") == "user":
|
| 94 |
+
last_user = index
|
| 95 |
+
messages = messages[last_user + 1 :] if last_user >= 0 else messages
|
| 96 |
+
|
| 97 |
+
total = TokenUsageTotals()
|
| 98 |
+
for message in messages:
|
| 99 |
+
if message.get("role") != "assistant":
|
| 100 |
+
continue
|
| 101 |
+
usage = message.get("usage")
|
| 102 |
+
if isinstance(usage, dict):
|
| 103 |
+
total = add_usage(total, totals_from_usage_dict(usage))
|
| 104 |
+
return total
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def sum_usage_from_jsonl(path: Path) -> TokenUsageTotals:
|
| 108 |
+
"""Parse a Pi session JSONL file and sum assistant ``usage`` blocks."""
|
| 109 |
+
total = TokenUsageTotals()
|
| 110 |
+
try:
|
| 111 |
+
text = path.read_text(encoding="utf-8")
|
| 112 |
+
except OSError:
|
| 113 |
+
return total
|
| 114 |
+
for line in text.splitlines():
|
| 115 |
+
stripped = line.strip()
|
| 116 |
+
if not stripped:
|
| 117 |
+
continue
|
| 118 |
+
try:
|
| 119 |
+
entry = json.loads(stripped)
|
| 120 |
+
except json.JSONDecodeError:
|
| 121 |
+
continue
|
| 122 |
+
if entry.get("type") != "message":
|
| 123 |
+
continue
|
| 124 |
+
message = entry.get("message")
|
| 125 |
+
if not isinstance(message, dict) or message.get("role") != "assistant":
|
| 126 |
+
continue
|
| 127 |
+
usage = message.get("usage")
|
| 128 |
+
if isinstance(usage, dict):
|
| 129 |
+
total = add_usage(total, totals_from_usage_dict(usage))
|
| 130 |
+
return total
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def resolve_session_token_usage(client: AgentRuntime | None) -> TokenUsageTotals:
|
| 134 |
+
"""
|
| 135 |
+
Best-effort session usage from Pi RPC ``get_session_stats``, live messages, or JSONL.
|
| 136 |
+
"""
|
| 137 |
+
if client is None or not client.running:
|
| 138 |
+
return TokenUsageTotals()
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
stats = client.get_session_stats()
|
| 142 |
+
totals = totals_from_stats_payload(stats)
|
| 143 |
+
if totals.input or totals.output or totals.cache_read or totals.cache_write:
|
| 144 |
+
return totals
|
| 145 |
+
except PiRpcError:
|
| 146 |
+
pass
|
| 147 |
+
|
| 148 |
+
try:
|
| 149 |
+
messages = client.get_messages()
|
| 150 |
+
totals = sum_usage_from_messages(messages)
|
| 151 |
+
if totals.input or totals.output or totals.cache_read or totals.cache_write:
|
| 152 |
+
return totals
|
| 153 |
+
except PiRpcError:
|
| 154 |
+
pass
|
| 155 |
+
|
| 156 |
+
from session_logs import pi_session_file_from_client
|
| 157 |
+
|
| 158 |
+
session_file = pi_session_file_from_client(client)
|
| 159 |
+
if session_file is not None:
|
| 160 |
+
return sum_usage_from_jsonl(session_file)
|
| 161 |
+
return TokenUsageTotals()
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def usage_for_completed_turn(
|
| 165 |
+
client: AgentRuntime | None,
|
| 166 |
+
baseline: TokenUsageTotals | None,
|
| 167 |
+
) -> TokenUsageTotals:
|
| 168 |
+
"""
|
| 169 |
+
Tokens consumed by the prompt that just finished.
|
| 170 |
+
|
| 171 |
+
Prefers delta from *baseline* (captured before ``prompt_events``). Falls back to
|
| 172 |
+
summing assistant ``usage`` since the last user message, then whole-session totals.
|
| 173 |
+
"""
|
| 174 |
+
if client is None or not client.running:
|
| 175 |
+
return TokenUsageTotals()
|
| 176 |
+
|
| 177 |
+
current = resolve_session_token_usage(client)
|
| 178 |
+
if baseline is not None:
|
| 179 |
+
delta = subtract_usage(current, baseline)
|
| 180 |
+
if delta.input or delta.output or delta.cache_read or delta.cache_write:
|
| 181 |
+
return delta
|
| 182 |
+
|
| 183 |
+
try:
|
| 184 |
+
turn = sum_usage_from_messages(client.get_messages(), since_last_user=True)
|
| 185 |
+
if turn.input or turn.output or turn.cache_read or turn.cache_write:
|
| 186 |
+
return turn
|
| 187 |
+
except PiRpcError:
|
| 188 |
+
pass
|
| 189 |
+
|
| 190 |
+
return current
|
agent-redact/pi/pi_workspace_skills.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sync doc_redaction skills into the Pi workspace and constrain Pi RPC to that tree."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
import stat
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from bootstrap_pi_config import pi_repo_root_path
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def workspace_base_dir() -> Path:
|
| 14 |
+
from session_workspace import workspace_base_dir as _base
|
| 15 |
+
|
| 16 |
+
return _base()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def workspace_pi_dir() -> Path:
|
| 20 |
+
return workspace_base_dir() / ".pi"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def workspace_skills_dir() -> Path:
|
| 24 |
+
return workspace_pi_dir() / "skills"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def workspace_helpers_dir() -> Path:
|
| 28 |
+
return workspace_pi_dir() / "helpers"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def remote_redaction_helper_path() -> Path:
|
| 32 |
+
"""Absolute path to synced ``remote_redaction.py`` (always under workspace base, not session subfolders)."""
|
| 33 |
+
return workspace_helpers_dir() / "remote_redaction.py"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def remote_redaction_helper_module() -> str:
|
| 37 |
+
return remote_redaction_helper_path().as_posix()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def repo_skills_dir() -> Path:
|
| 41 |
+
return pi_repo_root_path() / "skills"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _env_flag(name: str) -> bool:
|
| 45 |
+
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
_SKILLS_SKIP_DIR_NAMES = frozenset({"archive_attempts"})
|
| 49 |
+
_SKILLS_SKIP_SUFFIXES = (".b64.txt",)
|
| 50 |
+
_SKILLS_MAX_FILE_BYTES = int(
|
| 51 |
+
os.environ.get("AGENT_SKILLS_MAX_FILE_BYTES", str(512 * 1024))
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _should_skip_skill_relpath(rel: Path, *, size_bytes: int | None = None) -> bool:
|
| 56 |
+
"""Skip archive blobs and other non-skill artifacts during workspace sync."""
|
| 57 |
+
if any(part in _SKILLS_SKIP_DIR_NAMES for part in rel.parts):
|
| 58 |
+
return True
|
| 59 |
+
name_lower = rel.name.lower()
|
| 60 |
+
if name_lower.endswith(_SKILLS_SKIP_SUFFIXES):
|
| 61 |
+
return True
|
| 62 |
+
if size_bytes is not None and size_bytes > _SKILLS_MAX_FILE_BYTES:
|
| 63 |
+
return True
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _should_resync(dest: Path, src: Path) -> bool:
|
| 68 |
+
if _env_flag("AGENT_SKILLS_RESYNC"):
|
| 69 |
+
return True
|
| 70 |
+
if not dest.is_dir():
|
| 71 |
+
return True
|
| 72 |
+
if not any(dest.iterdir()):
|
| 73 |
+
return True
|
| 74 |
+
try:
|
| 75 |
+
return src.stat().st_mtime > dest.stat().st_mtime
|
| 76 |
+
except OSError:
|
| 77 |
+
return True
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _copy_tree_item(src: Path, dest: Path) -> None:
|
| 81 |
+
_copy_tree_item_filtered(src, dest, src_root=src)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _copy_to_workspace(src: Path, dest: Path) -> None:
|
| 85 |
+
"""Copy file content without metadata (copy2/copystat raises EPERM on bind mounts)."""
|
| 86 |
+
shutil.copyfile(src, dest)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _copy_tree_item_filtered(src: Path, dest: Path, *, src_root: Path) -> None:
|
| 90 |
+
rel = src.relative_to(src_root)
|
| 91 |
+
if _should_skip_skill_relpath(rel):
|
| 92 |
+
return
|
| 93 |
+
if src.is_file():
|
| 94 |
+
try:
|
| 95 |
+
size = src.stat().st_size
|
| 96 |
+
except OSError:
|
| 97 |
+
size = None
|
| 98 |
+
if size is not None and size > _SKILLS_MAX_FILE_BYTES:
|
| 99 |
+
return
|
| 100 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 101 |
+
if dest.exists():
|
| 102 |
+
_make_writable(dest)
|
| 103 |
+
_copy_to_workspace(src, dest)
|
| 104 |
+
return
|
| 105 |
+
if dest.exists():
|
| 106 |
+
for child in sorted(src.iterdir()):
|
| 107 |
+
_copy_tree_item_filtered(child, dest / child.name, src_root=src_root)
|
| 108 |
+
else:
|
| 109 |
+
dest.mkdir(parents=True, exist_ok=True)
|
| 110 |
+
for child in sorted(src.iterdir()):
|
| 111 |
+
_copy_tree_item_filtered(child, dest / child.name, src_root=src_root)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _chmod_tree(path: Path, *, writable: bool) -> None:
|
| 115 |
+
"""Set or clear write bits on a file tree (needed for Windows resync)."""
|
| 116 |
+
try:
|
| 117 |
+
if path.is_dir():
|
| 118 |
+
for root, dirs, files in os.walk(path):
|
| 119 |
+
root_path = Path(root)
|
| 120 |
+
for name in files:
|
| 121 |
+
file_path = root_path / name
|
| 122 |
+
mode = file_path.stat().st_mode
|
| 123 |
+
file_path.chmod(
|
| 124 |
+
(mode | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)
|
| 125 |
+
if writable
|
| 126 |
+
else (mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH)
|
| 127 |
+
)
|
| 128 |
+
for name in dirs:
|
| 129 |
+
dir_path = root_path / name
|
| 130 |
+
mode = dir_path.stat().st_mode
|
| 131 |
+
dir_path.chmod(
|
| 132 |
+
(mode | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)
|
| 133 |
+
if writable
|
| 134 |
+
else (mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH)
|
| 135 |
+
)
|
| 136 |
+
mode = path.stat().st_mode
|
| 137 |
+
path.chmod(
|
| 138 |
+
(mode | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)
|
| 139 |
+
if writable
|
| 140 |
+
else (mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH)
|
| 141 |
+
)
|
| 142 |
+
else:
|
| 143 |
+
mode = path.stat().st_mode
|
| 144 |
+
path.chmod(
|
| 145 |
+
(mode | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)
|
| 146 |
+
if writable
|
| 147 |
+
else (mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH)
|
| 148 |
+
)
|
| 149 |
+
except OSError:
|
| 150 |
+
pass
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _make_writable(path: Path) -> None:
|
| 154 |
+
_chmod_tree(path, writable=True)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _make_readonly(path: Path) -> None:
|
| 158 |
+
if _env_flag("AGENT_SKILLS_WRITABLE"):
|
| 159 |
+
return
|
| 160 |
+
_chmod_tree(path, writable=False)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def write_workspace_pi_settings() -> Path:
|
| 164 |
+
"""
|
| 165 |
+
Project Pi settings under ``{workspace}/.pi/settings.json``.
|
| 166 |
+
|
| 167 |
+
Paths in that file resolve relative to ``{workspace}/.pi/`` per Pi docs.
|
| 168 |
+
"""
|
| 169 |
+
pi_dir = workspace_pi_dir()
|
| 170 |
+
pi_dir.mkdir(parents=True, exist_ok=True)
|
| 171 |
+
settings_path = pi_dir / "settings.json"
|
| 172 |
+
payload = {
|
| 173 |
+
"skills": ["skills"],
|
| 174 |
+
"extensions": [],
|
| 175 |
+
"packages": [],
|
| 176 |
+
"enableSkillCommands": True,
|
| 177 |
+
}
|
| 178 |
+
import json
|
| 179 |
+
|
| 180 |
+
settings_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
| 181 |
+
return settings_path
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def sync_repo_skills_to_workspace(*, force: bool = False) -> Path:
|
| 185 |
+
"""
|
| 186 |
+
Copy ``{repo}/skills/`` β ``{workspace}/.pi/skills/`` (read-only for the agent).
|
| 187 |
+
|
| 188 |
+
Re-sync when the repo tree is newer or ``AGENT_SKILLS_RESYNC=true``.
|
| 189 |
+
"""
|
| 190 |
+
src = repo_skills_dir()
|
| 191 |
+
dest = workspace_skills_dir()
|
| 192 |
+
workspace_pi_dir().mkdir(parents=True, exist_ok=True)
|
| 193 |
+
|
| 194 |
+
if not src.is_dir():
|
| 195 |
+
dest.mkdir(parents=True, exist_ok=True)
|
| 196 |
+
write_workspace_pi_settings()
|
| 197 |
+
return dest
|
| 198 |
+
|
| 199 |
+
if force or _should_resync(dest, src):
|
| 200 |
+
if dest.exists():
|
| 201 |
+
_make_writable(dest)
|
| 202 |
+
shutil.rmtree(dest)
|
| 203 |
+
dest.mkdir(parents=True, exist_ok=True)
|
| 204 |
+
for item in sorted(src.iterdir()):
|
| 205 |
+
rel = item.relative_to(src)
|
| 206 |
+
try:
|
| 207 |
+
size = item.stat().st_size if item.is_file() else None
|
| 208 |
+
except OSError:
|
| 209 |
+
size = None
|
| 210 |
+
if _should_skip_skill_relpath(rel, size_bytes=size):
|
| 211 |
+
continue
|
| 212 |
+
_copy_tree_item_filtered(item, dest / item.name, src_root=src)
|
| 213 |
+
|
| 214 |
+
write_workspace_pi_settings()
|
| 215 |
+
os.environ["AGENT_WORKSPACE_SKILLS_DIR"] = str(dest.resolve())
|
| 216 |
+
return dest.resolve()
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def sync_workspace_helpers() -> Path:
|
| 220 |
+
"""
|
| 221 |
+
Copy Pi redaction helper scripts into ``{workspace}/.pi/helpers/``.
|
| 222 |
+
|
| 223 |
+
Keeps ``remote_redaction.py`` inside the workspace boundary on AWS ECS so the
|
| 224 |
+
agent does not search ``/workspace/doc_redaction/agent-redact/``.
|
| 225 |
+
"""
|
| 226 |
+
helpers = workspace_helpers_dir()
|
| 227 |
+
helpers.mkdir(parents=True, exist_ok=True)
|
| 228 |
+
shared_dir = Path(__file__).resolve().parent.parent / "shared"
|
| 229 |
+
for name in ("remote_redaction.py", "run_doc_redact.py"):
|
| 230 |
+
src = shared_dir / name
|
| 231 |
+
dest = helpers / name
|
| 232 |
+
if not src.is_file():
|
| 233 |
+
continue
|
| 234 |
+
if not dest.is_file() or src.stat().st_mtime > dest.stat().st_mtime:
|
| 235 |
+
_copy_to_workspace(src, dest)
|
| 236 |
+
return helpers.resolve()
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def write_hf_space_deployment_skill(*, force: bool = False) -> Path | None:
|
| 240 |
+
"""
|
| 241 |
+
Write a deployment-specific skill that overrides Docker URLs in generic skills.
|
| 242 |
+
|
| 243 |
+
Only active when ``AGENT_DEPLOYMENT_PROFILE=hf-space``.
|
| 244 |
+
"""
|
| 245 |
+
try:
|
| 246 |
+
from pi_agent_config import is_hf_space_profile
|
| 247 |
+
from redaction_prompt import doc_redaction_gradio_url
|
| 248 |
+
except ImportError:
|
| 249 |
+
return None
|
| 250 |
+
if not is_hf_space_profile():
|
| 251 |
+
return None
|
| 252 |
+
|
| 253 |
+
skills_root = workspace_skills_dir()
|
| 254 |
+
skills_root.mkdir(parents=True, exist_ok=True)
|
| 255 |
+
if skills_root.is_dir():
|
| 256 |
+
_make_writable(skills_root)
|
| 257 |
+
|
| 258 |
+
dest_dir = skills_root / "hf-space-deployment"
|
| 259 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 260 |
+
dest = dest_dir / "SKILL.md"
|
| 261 |
+
url = doc_redaction_gradio_url()
|
| 262 |
+
helpers = workspace_helpers_dir().as_posix()
|
| 263 |
+
content = (
|
| 264 |
+
"# HF Space deployment (read first)\n\n"
|
| 265 |
+
"This Pi agent runs on **Hugging Face Spaces** with **Gemini** and calls a "
|
| 266 |
+
"**remote** doc_redaction Space. Generic skills mention Docker URLs for "
|
| 267 |
+
"local-docker or AWS ECS β **ignore those here**.\n\n"
|
| 268 |
+
"## Authoritative settings\n\n"
|
| 269 |
+
"| Setting | Value |\n"
|
| 270 |
+
"|---------|--------|\n"
|
| 271 |
+
f"| **doc_redaction URL** | `{url}` **only** |\n"
|
| 272 |
+
"| **Auth** | `HF_TOKEN` (Space secret; already in Pi subprocess env) |\n"
|
| 273 |
+
f"| **Helper module** | `{helpers}/remote_redaction.py` |\n\n"
|
| 274 |
+
"## One-shot CLI (preferred over writing ``run_redact.py``)\n\n"
|
| 275 |
+
f"```bash\n"
|
| 276 |
+
f"python3 {helpers}/run_doc_redact.py \\\n"
|
| 277 |
+
f' --pdf "<session-folder>/document.pdf" \\\n'
|
| 278 |
+
f' --dest "<session-folder>/redact/document.pdf/output_redact/" \\\n'
|
| 279 |
+
f' --ocr-method "Local model - selectable text" \\\n'
|
| 280 |
+
f' --pii-method "Local"\n'
|
| 281 |
+
f"```\n\n"
|
| 282 |
+
"## Minimal Python (only if the CLI is insufficient)\n\n"
|
| 283 |
+
"```python\n"
|
| 284 |
+
"import importlib.util\n"
|
| 285 |
+
"import sys\n"
|
| 286 |
+
f'helper = "{helpers}/remote_redaction.py"\n'
|
| 287 |
+
'spec = importlib.util.spec_from_file_location("remote_redaction", helper)\n'
|
| 288 |
+
"mod = importlib.util.module_from_spec(spec)\n"
|
| 289 |
+
'sys.modules["remote_redaction"] = mod\n'
|
| 290 |
+
"spec.loader.exec_module(mod)\n"
|
| 291 |
+
"from gradio_client import handle_file\n\n"
|
| 292 |
+
f"client = mod.make_redaction_client() # URL: {url}\n"
|
| 293 |
+
'pdf = "<your-session-folder>/document.pdf"\n'
|
| 294 |
+
"result = client.predict(\n"
|
| 295 |
+
' api_name="/doc_redact",\n'
|
| 296 |
+
" document_file=handle_file(pdf),\n"
|
| 297 |
+
")\n"
|
| 298 |
+
'paths = mod.resolve_redaction_output_paths(result, document_stem="document")\n'
|
| 299 |
+
'mod.fetch_redaction_files(paths, "<your-session-folder>/redact/document/output_redact/")\n'
|
| 300 |
+
"```\n\n"
|
| 301 |
+
"## Rules\n\n"
|
| 302 |
+
f"- **Helper path is shared:** `{helpers}/remote_redaction.py` lives under the "
|
| 303 |
+
f"workspace root `{workspace_base_dir().as_posix()}/`, **not** under your session "
|
| 304 |
+
f"subfolder's `.pi/` tree.\n"
|
| 305 |
+
f"- Call `/doc_redact` via `{helpers}/run_doc_redact.py` or `make_redaction_client()`.\n"
|
| 306 |
+
"- **Do not** create `run_redact.py`, `run_redact_fixed.py`, or duplicate helpers in your session folder.\n"
|
| 307 |
+
"- **Do not** call `Client(...)` or `view_api()` in a loop from bash β each call hits HF rate limits. "
|
| 308 |
+
"Use the CLI once, or one `make_redaction_client()` (cached + retries).\n"
|
| 309 |
+
"- **Do not** pass `base_url=` manually β `make_redaction_client()` reads "
|
| 310 |
+
f"`DOC_REDACTION_GRADIO_URL` (`{url}`).\n"
|
| 311 |
+
"- **Do not** use `host.docker.internal`, `localhost`, `redaction:7861`, or probe "
|
| 312 |
+
"alternate URLs.\n"
|
| 313 |
+
"- **Do not** rewrite or duplicate `remote_redaction.py` β use the synced helper.\n"
|
| 314 |
+
"- On `TooManyRequestsError`, wait at least 60s and retry **once** via the CLI β "
|
| 315 |
+
"do not spawn repeated `python3 -c` Client probes.\n"
|
| 316 |
+
"- Write status updates as **normal assistant text**, not bash `#` comments.\n"
|
| 317 |
+
"- After `/doc_redact`, download outputs with `fetch_redaction_files` into your "
|
| 318 |
+
"session `output_redact/` folder.\n\n"
|
| 319 |
+
"Then read `/skill:doc-redaction-app` and `/skill:doc-redaction-modifications` "
|
| 320 |
+
"for workflow steps, substituting the URL above wherever examples show Docker hosts.\n"
|
| 321 |
+
)
|
| 322 |
+
if force or not dest.is_file() or dest.read_text(encoding="utf-8") != content:
|
| 323 |
+
dest.write_text(content, encoding="utf-8")
|
| 324 |
+
return dest
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def ensure_workspace_skills(*, force: bool = False) -> Path:
|
| 328 |
+
"""Idempotent sync used at app startup and before Pi RPC starts."""
|
| 329 |
+
dest = sync_repo_skills_to_workspace(force=force)
|
| 330 |
+
sync_workspace_helpers()
|
| 331 |
+
write_hf_space_deployment_skill(force=force)
|
| 332 |
+
if dest.is_dir():
|
| 333 |
+
_make_readonly(dest)
|
| 334 |
+
return dest
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def partnership_template_in_workspace() -> Path | None:
|
| 338 |
+
path = workspace_skills_dir() / "Example prompt partnership.txt"
|
| 339 |
+
return path if path.is_file() else None
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def pi_rpc_cwd(session_hash: str | None = None) -> str:
|
| 343 |
+
"""Subprocess cwd for ``pi --mode rpc`` (session subfolder when enabled)."""
|
| 344 |
+
from session_workspace import session_workspace_dir, session_workspace_enabled
|
| 345 |
+
|
| 346 |
+
base = workspace_base_dir()
|
| 347 |
+
if session_hash and session_hash.strip() and session_workspace_enabled():
|
| 348 |
+
return str(session_workspace_dir(session_hash))
|
| 349 |
+
return str(base)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def pi_rpc_args() -> list[str]:
|
| 353 |
+
"""Load only workspace skills; do not discover repo ``skills/`` via ancestors."""
|
| 354 |
+
skills_dir = ensure_workspace_skills()
|
| 355 |
+
return ["--no-skills", "--skill", str(skills_dir)]
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def workspace_boundary_prefix(session_hash: str | None = None) -> str:
|
| 359 |
+
"""Extra prompt text: workspace root, skills path, and path rules."""
|
| 360 |
+
base = workspace_base_dir().as_posix().rstrip("/")
|
| 361 |
+
skills = workspace_skills_dir().as_posix()
|
| 362 |
+
from session_workspace import session_workspace_dir, session_workspace_enabled
|
| 363 |
+
|
| 364 |
+
if session_hash and session_hash.strip() and session_workspace_enabled():
|
| 365 |
+
root = session_workspace_dir(session_hash).as_posix().rstrip("/")
|
| 366 |
+
scope = f"your session folder `{root}/`"
|
| 367 |
+
else:
|
| 368 |
+
root = base
|
| 369 |
+
scope = f"the workspace `{base}/`"
|
| 370 |
+
|
| 371 |
+
hf_note = ""
|
| 372 |
+
try:
|
| 373 |
+
from pi_agent_config import is_hf_space_profile
|
| 374 |
+
from redaction_prompt import doc_redaction_gradio_url
|
| 375 |
+
|
| 376 |
+
if is_hf_space_profile():
|
| 377 |
+
helpers = remote_redaction_helper_module()
|
| 378 |
+
hf_note = (
|
| 379 |
+
f"**HF Space redaction backend:** use `{doc_redaction_gradio_url()}` only "
|
| 380 |
+
f"(see `/skill:hf-space-deployment`). Import helpers from `{helpers}` "
|
| 381 |
+
f"(workspace base β not `{root}/.pi/helpers/`). Do not use Docker host "
|
| 382 |
+
"URLs from other skills. Write user-facing progress as normal chat text, "
|
| 383 |
+
"not bash comments.\n\n"
|
| 384 |
+
)
|
| 385 |
+
except ImportError:
|
| 386 |
+
pass
|
| 387 |
+
|
| 388 |
+
return (
|
| 389 |
+
f"**Workspace boundary (mandatory):** work only under `{base}/`. "
|
| 390 |
+
f"Your active directory is {scope}. "
|
| 391 |
+
f"Do not read, write, or run shell commands targeting paths outside `{base}/` "
|
| 392 |
+
f"(including the git checkout and `agent-redact/`). "
|
| 393 |
+
f"**Skills (read-only):** doc_redaction skills are synced to `{skills}/`. "
|
| 394 |
+
f"Use `/skill:doc-redaction-app`, `/skill:doc-redact-page-review`, etc. "
|
| 395 |
+
f"Do not edit files under `{skills}/`.\n\n"
|
| 396 |
+
f"{hf_note}"
|
| 397 |
+
)
|
agent-redact/redaction_langgraph/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""LangGraph-based redaction orchestration (curated tools, no shell)."""
|