document_redaction / test /test_langgraph_tools.py
seanpedrickcase's picture
Sync: Fixes to image annotation component for compatibility with gradio 6.26.0
2677bc3
Raw
History Blame Contribute Delete
33.8 kB
"""Tests for LangGraph redaction tools (coverage, workspace I/O)."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from pi_test_support import ensure_agent_redact_paths, ensure_gradio_importable
ensure_agent_redact_paths()
ensure_gradio_importable()
from redaction_langgraph.tools import ( # noqa: E402
_coerce_relative_path,
_coerce_str_list,
_coerce_tool_text_content,
_default_dest_for_pdf,
_default_review_apply_dest_for_review_csv,
_discover_ocr_words_csv,
_ensure_workspace_output_dir,
_merge_redact_entities,
_normalize_review_color_cell,
_parse_doc_redact_tool_input,
_parse_review_apply_tool_input,
_parse_write_workspace_text_input,
_remember_session_artifacts,
_repair_review_csv_body,
_resolve_optional_redacted_pdf,
_resolve_workspace_path,
_resolve_workspace_pdf,
_validate_review_csv_body,
normalize_tool_args,
read_workspace_text,
reset_langgraph_tool_session_state,
run_doc_redact,
run_review_apply,
run_verify_coverage,
write_workspace_text,
)
from redaction_langgraph.verify_coverage_lib import ( # noqa: E402
compile_patterns,
normalize_regex_patterns,
)
def test_normalize_review_color_cell():
assert _normalize_review_color_cell("(12, 34, 56)") == "(12, 34, 56)"
assert _normalize_review_color_cell("0,0,0") == "(0, 0, 0)"
assert _normalize_review_color_cell("black") == "(0, 0, 0)"
assert _normalize_review_color_cell("#ff0000") == "(255, 0, 0)"
assert _normalize_review_color_cell("placeholder") == "(0, 0, 0)"
assert _normalize_review_color_cell("") == "(0, 0, 0)"
assert _normalize_review_color_cell((1, 2, 3)) == "(1, 2, 3)"
def test_repair_review_csv_body_colors():
body = (
"page,xmin,xmax,ymin,ymax,color,text\n"
"1,0.1,0.2,0.3,0.4,black,Name\n"
"1,0.1,0.2,0.3,0.4,0,0,0,Other\n"
)
# The second row has broken CSV because color "0,0,0" splits columns —
# use a quoted form instead.
body = (
"page,xmin,xmax,ymin,ymax,color,text\n"
"1,0.1,0.2,0.3,0.4,black,Name\n"
'1,0.2,0.3,0.4,0.5,"0, 0, 0",Other\n'
"1,0.3,0.4,0.5,0.6,placeholder,Third\n"
)
repaired, changed = _repair_review_csv_body(body)
assert changed == 3
assert "(0, 0, 0)" in repaired
assert "black" not in repaired
assert "placeholder" not in repaired
def test_write_workspace_text_repairs_review_colors(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
body = "page,xmin,xmax,ymin,ymax,color,text\n" "1,0.1,0.2,0.3,0.4,black,Name\n"
out = write_workspace_text(
"redact/doc/output_redact/doc_review_file.csv",
body,
session_hash="sess",
)
data = json.loads(out)
assert "error" not in data
assert data.get("color_cells_repaired") == 1
written = (
tmp_path / "sess" / "redact" / "doc" / "output_redact" / "doc_review_file.csv"
)
text = written.read_text(encoding="utf-8-sig")
assert "(0, 0, 0)" in text
assert "black" not in text
def test_discover_ocr_words_csv(tmp_path):
review = tmp_path / "doc_review_file.csv"
review.write_text("id,page\n", encoding="utf-8-sig")
words = tmp_path / "doc_word_level_ocr.csv"
words.write_text("word_text,page\n", encoding="utf-8-sig")
assert _discover_ocr_words_csv(review) == words
def test_discover_ocr_results_with_words_preferred(tmp_path):
review = tmp_path / "doc_review_file.csv"
review.write_text("id,page\n", encoding="utf-8-sig")
other = tmp_path / "doc_ocr_summary.csv"
other.write_text("a,b\n", encoding="utf-8-sig")
words = tmp_path / "doc_0_0_ocr_results_with_words_local_ocr.csv"
words.write_text("word_text,page\n", encoding="utf-8-sig")
assert _discover_ocr_words_csv(review) == words
def test_normalize_tool_args_flattens_nested_path():
out = normalize_tool_args(
"doc_redact",
{"pdf_relative_path": {"pdf_relative_path": "file.pdf"}},
)
assert out["pdf_relative_path"] == "file.pdf"
def test_normalize_tool_args_wrong_inner_key():
out = normalize_tool_args(
"doc_redact",
{"pdf_relative_path": {"relative_path": "file.pdf"}},
)
assert out["pdf_relative_path"] == "file.pdf"
def test_validate_review_csv_rejects_placeholder():
body = "page,xmin,xmax,ymin,ymax,text\n1,placeholder,0.2,0.1,0.2,Name\n"
err = _validate_review_csv_body(body)
assert err is not None
assert "placeholder" in err.lower()
def test_validate_review_csv_accepts_numeric():
body = "page,xmin,xmax,ymin,ymax,text\n1,0.1,0.2,0.3,0.4,Name\n"
assert _validate_review_csv_body(body) is None
def test_write_workspace_text_rejects_placeholder_review_csv(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
body = "page,xmin,xmax,ymin,ymax,text\n1,placeholder,0.2,0.1,0.2,Name\n"
out = write_workspace_text(
"redact/doc/output_redact/doc_review_file.csv",
body,
session_hash="sess",
)
data = json.loads(out)
assert "error" in data
assert "placeholder" in data["error"].lower()
def test_write_storm_blocks_third_python_rewrite(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
script_a = "import csv\nprint(1)\n"
script_b = "import csv\nprint(2)\n"
script_c = "import csv\nprint(3)\n"
out1 = write_workspace_text("fix_review.py", script_a, session_hash="sess")
assert "error" not in json.loads(out1)
out2 = write_workspace_text("fix_review.py", script_b, session_hash="sess")
assert "error" not in json.loads(out2)
out3 = write_workspace_text("fix_review.py", script_c, session_hash="sess")
data3 = json.loads(out3)
# Third rewrite auto-runs the already-saved script instead of error-looping.
assert data3.get("blocked_write_storm") is True
assert data3.get("auto_ran") is True
assert "error" not in data3
assert isinstance(data3.get("run"), dict)
assert data3["run"].get("returncode") == 0
assert "2" in (data3["run"].get("stdout") or "")
def test_resolve_script_prefers_content_output_redact(tmp_path, monkeypatch):
from redaction_langgraph.tools import _resolve_script_relative_path
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
session = tmp_path / "sess"
old = (
session
/ "redact"
/ "example_of_emails_sent_to_a_professor_before_applying"
/ "output_redact"
)
new = session / "redact" / "graduate-job-example-cover-letter" / "output_redact"
old.mkdir(parents=True)
new.mkdir(parents=True)
(old / "analyze_ocr.py").write_text("print('old')\n", encoding="utf-8")
content = (
'ocr_path = "redact/graduate-job-example-cover-letter/output_redact/'
'doc_ocr_results_with_words_local_ocr.csv"\n'
)
rel = _resolve_script_relative_path("sess", "analyze_ocr.py", content=content)
assert rel == (
"redact/graduate-job-example-cover-letter/output_redact/analyze_ocr.py"
)
def test_read_workspace_text_autofills_empty_dict(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
session = tmp_path / "sess"
review = session / "redact" / "doc" / "output_redact" / "doc_review_file.csv"
ocr = (
session
/ "redact"
/ "doc"
/ "output_redact"
/ "doc_ocr_results_with_words_local_ocr.csv"
)
review.parent.mkdir(parents=True)
review.write_text("page,text\n1,hello\n", encoding="utf-8-sig")
ocr.write_text("word_text,page\nhello,1\n", encoding="utf-8-sig")
_remember_session_artifacts(
"sess",
review_csv_relative_path=str(review.relative_to(session)).replace("\\", "/"),
ocr_words_csv_relative_path=str(ocr.relative_to(session)).replace("\\", "/"),
)
out1 = read_workspace_text({}, session_hash="sess")
assert "auto-filled relative_path=" in out1
assert "doc_review_file.csv" in out1
assert "hello" in out1
# Second empty read rotates to OCR words CSV.
out2 = read_workspace_text({"relative_path": {}}, session_hash="sess")
assert "ocr_results_with_words" in out2
assert "hello" in out2
def test_read_workspace_text_empty_dict_discovers_review_on_disk(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
session = tmp_path / "sess"
review = session / "output_redact" / "example_review_file.csv"
review.parent.mkdir(parents=True)
review.write_text("page,text\n1,world\n", encoding="utf-8-sig")
out = read_workspace_text({}, session_hash="sess")
assert "error" not in out[:20]
assert "world" in out
assert "example_review_file.csv" in out
def test_write_content_soft_limit(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
monkeypatch.setenv("LANGGRAPH_MAX_WRITE_CONTENT_BYTES", "100")
# Re-import bound constant is read at module load; patch the module attribute.
import redaction_langgraph.tools as tools_mod
monkeypatch.setattr(tools_mod, "_MAX_WRITE_CONTENT_BYTES", 100)
reset_langgraph_tool_session_state("sess")
body = "x" * 150
out = write_workspace_text("notes.txt", body, session_hash="sess")
data = json.loads(out)
assert "error" in data
assert "too large" in data["error"].lower()
def test_read_workspace_text_missing_file(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
out = read_workspace_text("missing.csv", session_hash="sess")
payload = json.loads(out)
assert "error" in payload
def test_workspace_text_roundtrip(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
out = write_workspace_text("a.txt", "hello", session_hash="sess")
assert json.loads(out)["written"] == "a.txt"
assert read_workspace_text("a.txt", session_hash="sess") == "hello"
def test_normalize_regex_patterns_pipe_string():
raw = r"Hyde|Lauren\s+Lilley|Lauren|Lilley|University\s+of\s+Notre\s+Dame|Notre\s+Dame"
assert normalize_regex_patterns(raw) == [
"Hyde",
r"Lauren\s+Lilley",
"Lauren",
"Lilley",
r"University\s+of\s+Notre\s+Dame",
r"Notre\s+Dame",
]
def test_compile_patterns_accepts_pipe_string_not_characters():
patterns = compile_patterns(r"Kornbluth|Poss\b")
assert len(patterns) == 2
assert patterns[0].search("Kornbluth")
assert patterns[1].search("Poss")
def test_compile_patterns_user_pipe_string():
raw = r"Hyde|Lauren|Lilley|University of Notre Dame|David R\."
patterns = compile_patterns(raw)
assert len(patterns) == 5
assert patterns[0].search("Hyde")
assert patterns[-1].search("David R.")
def test_compile_patterns_fallback_literal_on_invalid_regex():
patterns = compile_patterns(r"bad[")
assert len(patterns) == 1
assert patterns[0].search("bad[")
def test_coerce_relative_path_from_nested_dict():
assert (
_coerce_relative_path(
{"dest_relative_dir": "redact/doc/output_redact"},
label="dest_relative_dir",
)
== "redact/doc/output_redact"
)
def test_resolve_workspace_path_accepts_dict_path(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
resolved = _resolve_workspace_path(
"sess",
{"pdf_relative_path": "uploads/doc.pdf"},
)
assert resolved == (tmp_path / "sess" / "uploads" / "doc.pdf").resolve()
def test_run_doc_redact_accepts_merged_tool_args_dict(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
session = tmp_path / "sess"
pdf = session / "uploads" / "doc.pdf"
pdf.parent.mkdir(parents=True)
pdf.write_bytes(b"%PDF-1.4")
captured: dict[str, Any] = {}
def fake_call_doc_redact(pdf_path, dest_dir, **kwargs):
captured["pdf"] = Path(pdf_path)
captured["dest"] = Path(dest_dir)
captured["kwargs"] = kwargs
return (
["C:/server/out/doc_review_file.csv"],
[dest_dir / "doc_review_file.csv"],
)
monkeypatch.setattr(
"redaction_langgraph.tools.call_doc_redact",
fake_call_doc_redact,
)
payload = {
"pdf_relative_path": "uploads/doc.pdf",
"dest_relative_dir": "redact/doc/output_redact",
}
out = run_doc_redact(payload, None, session_hash="sess")
data = json.loads(out)
assert "error" not in data
assert captured["pdf"] == pdf.resolve()
assert captured["dest"] == (session / "redact/doc/output_redact").resolve()
def test_parse_doc_redact_tool_input_ignores_garbage_keys():
messy = {
"pdf_path": "example_of_emails_sent_to_a_professor_before_applying.pdf",
"ocr_method": "Local model - selectable text",
"pii_method": "Local",
"}] }' http://host.docker.internal:7861/api/call/doc_redact": -1,
}
pdf_rel, dest_rel, ocr, pii, *_ = _parse_doc_redact_tool_input(
"ignored.pdf",
messy,
ocr_method=None,
pii_method=None,
)
assert pdf_rel == "example_of_emails_sent_to_a_professor_before_applying.pdf"
assert dest_rel == (
"redact/example_of_emails_sent_to_a_professor_before_applying/output_redact"
)
assert ocr == "Local model - selectable text"
assert pii == "Local"
def test_parse_doc_redact_nested_absolute_path_key(tmp_path, monkeypatch):
"""Local Qwen models nest args under an absolute path dict key."""
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
abs_path = (
tmp_path / "sess" / "example_of_emails_sent_to_a_professor_before_applying.pdf"
).as_posix()
messy = {
"pdf_relative_path": {
abs_path: {
"pdf_relative_path": (
"example_of_emails_sent_to_a_professor_before_applying.pdf"
),
"dest_relative_dir": (
"redact/example_of_emails_sent_to_a_professor_before_applying/"
"output_redact"
),
}
}
}
pdf_rel, dest_rel, ocr, pii, *_ = _parse_doc_redact_tool_input(
messy,
None,
ocr_method=None,
pii_method=None,
session_hash="sess",
)
assert pdf_rel == "example_of_emails_sent_to_a_professor_before_applying.pdf"
assert dest_rel == (
"redact/example_of_emails_sent_to_a_professor_before_applying/output_redact"
)
assert ocr is None
assert pii is None
def test_parse_doc_redact_output_redact_as_pdf_autodiscovers(tmp_path, monkeypatch):
"""Models often pass output_redact in the PDF slot after compaction."""
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
session = tmp_path / "sess"
session.mkdir()
(session / "letter.pdf").write_bytes(b"%PDF-1.4")
out = session / "redact" / "letter" / "output_redact"
out.mkdir(parents=True)
pdf_rel, dest_rel, ocr, pii, *_ = _parse_doc_redact_tool_input(
"output_redact",
None,
ocr_method=None,
pii_method=None,
session_hash="sess",
)
assert pdf_rel == "letter.pdf"
assert "output_redact" in dest_rel.replace("\\", "/")
assert ocr is None
assert pii is None
def test_parse_doc_redact_empty_object_autodiscovers_single_pdf(tmp_path, monkeypatch):
"""Local models often emit pdf_relative_path={} after losing the filename."""
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
session = tmp_path / "sess"
session.mkdir()
(session / "example.pdf").write_bytes(b"%PDF-1.4")
# Redacted outputs must not be chosen as the source.
out = session / "redact" / "example" / "output_redact"
out.mkdir(parents=True)
(out / "example_redacted.pdf").write_bytes(b"%PDF-1.4")
pdf_rel, dest_rel, ocr, pii, *_ = _parse_doc_redact_tool_input(
{"pdf_relative_path": {}},
None,
ocr_method=None,
pii_method=None,
session_hash="sess",
)
assert pdf_rel == "example.pdf"
assert dest_rel == "redact/example/output_redact"
assert ocr is None
assert pii is None
def test_parse_doc_redact_empty_object_lists_choices_when_ambiguous(
tmp_path, monkeypatch
):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
session = tmp_path / "sess"
session.mkdir()
(session / "a.pdf").write_bytes(b"%PDF-1.4")
(session / "b.pdf").write_bytes(b"%PDF-1.4")
try:
_parse_doc_redact_tool_input(
{},
None,
ocr_method=None,
pii_method=None,
session_hash="sess",
)
raise AssertionError("expected ValueError for ambiguous PDFs")
except ValueError as exc:
msg = str(exc)
assert "plain string" in msg
assert "a.pdf" in msg
assert "b.pdf" in msg
def test_coerce_str_list_and_merge_redact_entities():
assert _coerce_str_list("CUSTOM_VLM_FACES, CUSTOM_VLM_SIGNATURE") == [
"CUSTOM_VLM_FACES",
"CUSTOM_VLM_SIGNATURE",
]
assert _coerce_str_list('["CUSTOM_VLM_FACES"]') == ["CUSTOM_VLM_FACES"]
assert _coerce_str_list(["PERSON", "CUSTOM_VLM_FACES"]) == [
"PERSON",
"CUSTOM_VLM_FACES",
]
merged = _merge_redact_entities(["CUSTOM_VLM_FACES"])
assert merged is not None
assert merged[0] == "PERSON"
assert "CUSTOM" in merged
assert merged[-1] == "CUSTOM_VLM_FACES"
assert _merge_redact_entities(None) is None
def test_parse_doc_redact_extracts_vlm_entities_and_handwrite():
pdf_rel, dest_rel, ocr, pii, entities, deny, allow, handwrite = (
_parse_doc_redact_tool_input(
{
"pdf_relative_path": "faces.pdf",
"redact_entities": ["CUSTOM_VLM_FACES", "CUSTOM_VLM_SIGNATURE"],
"deny_list": "Alice,Bob",
"handwrite_signature_checkbox": ["Extract signatures"],
},
None,
ocr_method=None,
pii_method=None,
)
)
assert pdf_rel == "faces.pdf"
assert entities == ["CUSTOM_VLM_FACES", "CUSTOM_VLM_SIGNATURE"]
assert deny == ["Alice", "Bob"]
assert allow is None
assert handwrite == ["Extract signatures"]
assert ocr is None and pii is None
assert "output_redact" in dest_rel
def test_run_doc_redact_forwards_vlm_entities(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
session = tmp_path / "sess"
pdf = session / "uploads" / "doc.pdf"
pdf.parent.mkdir(parents=True)
pdf.write_bytes(b"%PDF-1.4")
captured: dict[str, Any] = {}
def fake_call_doc_redact(pdf_path, dest_dir, **kwargs):
captured["pdf"] = Path(pdf_path)
captured["dest"] = Path(dest_dir)
captured["kwargs"] = kwargs
out_csv = Path(dest_dir) / "doc_review_file.csv"
out_csv.parent.mkdir(parents=True, exist_ok=True)
out_csv.write_text("page,text\n", encoding="utf-8")
return (["C:/server/out/doc_review_file.csv"], [out_csv])
monkeypatch.setattr(
"redaction_langgraph.tools.call_doc_redact",
fake_call_doc_redact,
)
out = run_doc_redact(
"uploads/doc.pdf",
"redact/doc/output_redact",
session_hash="sess",
redact_entities=["CUSTOM_VLM_FACES"],
handwrite_signature_checkbox=["Extract signatures"],
deny_list=["SecretOrg"],
)
data = json.loads(out)
assert "error" not in data
entities = captured["kwargs"]["redact_entities"]
assert "PERSON" in entities
assert "CUSTOM_VLM_FACES" in entities
assert captured["kwargs"]["handwrite_signature_checkbox"] == ["Extract signatures"]
assert captured["kwargs"]["deny_list"] == ["SecretOrg"]
assert data["redact_entities"][-1] == "CUSTOM_VLM_FACES"
def test_default_dest_for_pdf():
assert _default_dest_for_pdf("uploads/doc.pdf") == "redact/doc/output_redact"
def test_resolve_workspace_pdf_by_basename(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
session = tmp_path / "sess"
pdf = session / "nested" / "doc.pdf"
pdf.parent.mkdir(parents=True)
pdf.write_bytes(b"%PDF-1.4")
resolved = _resolve_workspace_pdf("sess", "doc.pdf")
assert resolved == pdf.resolve()
def test_read_workspace_text_nested_relative_path(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
session = tmp_path / "sess"
csv_path = session / "redact" / "doc_review_file.csv"
csv_path.parent.mkdir(parents=True)
csv_path.write_text("id,page\n1,1\n", encoding="utf-8-sig")
nested = {
"relative_path": {
"relative_path": "redact/doc_review_file.csv",
}
}
assert read_workspace_text(nested, session_hash="sess") == "id,page\n1,1\n"
def test_read_workspace_text_csv_preview(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
monkeypatch.setenv("LANGGRAPH_READ_CSV_MAX_LINES", "2")
session = tmp_path / "sess"
csv_path = session / "big.csv"
session.mkdir(parents=True)
csv_path.write_text("a\nb\nc\nd\n", encoding="utf-8-sig")
out = read_workspace_text("big.csv", session_hash="sess")
assert "CSV preview" in out
assert "lines 1-2 of 4" in out
assert "a\nb" in out
assert "\nc\n" not in out
def test_coerce_tool_text_content_extension_key_dict():
script = "import csv\nprint('ok')\n"
assert _coerce_tool_text_content({".py": script}) == script
def test_write_workspace_text_messy_local_model_args(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
script = "import csv\nprint('ok')\n"
messy = {
"relative_path": {
"relative_path": "redact/example/fix_policy.py",
},
"content": {".py": script},
}
out = write_workspace_text(messy, None, session_hash="sess")
data = json.loads(out)
assert "error" not in data
written = tmp_path / "sess" / "redact" / "example" / "fix_policy.py"
assert written.read_text(encoding="utf-8-sig") == script
def test_parse_write_workspace_text_input():
script = "import csv\n"
rel, body = _parse_write_workspace_text_input(
{
"relative_path": {"relative_path": "redact/a/fix_policy.py"},
"content": {".py": script},
},
None,
)
assert rel == "redact/a/fix_policy.py"
assert body == script
def test_parse_write_workspace_text_input_script_content_dict():
script = "import csv\nprint('ok')\n"
rel, body = _parse_write_workspace_text_input(
{"script": "fix_policy.py", "content": script},
None,
)
assert rel == "fix_policy.py"
assert body == script
def test_parse_write_workspace_text_input_doubly_nested():
script = "import csv\n"
rel, body = _parse_write_workspace_text_input(
{
"relative_path": {"relative_path": "fix_review.py"},
"content": {"content": script},
},
None,
)
assert rel == "fix_review.py"
assert body == script
def test_parse_write_workspace_text_swapped_path_and_content():
script = "import csv\nimport io\nprint('ok')\n"
rel, body = _parse_write_workspace_text_input(script, "fix_review.py")
assert rel == "fix_review.py"
assert body == script
def test_parse_write_workspace_text_rejects_content_as_path():
script = "import csv\n" + ("x = 1\n" * 50)
try:
_parse_write_workspace_text_input(script, "# also a body\nprint(1)\n")
raise AssertionError("expected ValueError")
except ValueError as exc:
assert "relative_path looks like file content" in str(exc)
def test_write_workspace_text_swapped_args(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
script = "import csv\nprint('ok')\n"
out = write_workspace_text(script, "fix_review.py", session_hash="sess")
data = json.loads(out)
assert "error" not in data, data
written = tmp_path / "sess" / "scripts" / "fix_review.py"
assert written.read_text(encoding="utf-8-sig") == script
def test_write_workspace_text_python_next_step(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
script = "import csv\nprint('ok')\n"
out = write_workspace_text(
{
"relative_path": {"relative_path": "fix_review.py"},
"content": {"content": script},
},
None,
session_hash="sess",
)
data = json.loads(out)
assert "error" not in data
assert data["written"].endswith("fix_review.py")
assert "next_step" in data
assert "run_workspace_python_script" in data["next_step"]
out2 = write_workspace_text(
{
"relative_path": {"relative_path": "fix_review.py"},
"content": {"content": script},
},
None,
session_hash="sess",
)
data2 = json.loads(out2)
assert data2.get("unchanged") is True
def test_write_workspace_text_script_content_dict(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
script = "import csv\nprint('ok')\n"
out = write_workspace_text(
{"script": "fix_policy.py", "content": script},
None,
session_hash="sess",
)
data = json.loads(out)
assert "error" not in data
written = tmp_path / "sess" / "scripts" / "fix_policy.py"
assert written.read_text(encoding="utf-8-sig") == script
assert "next_step" in data
def test_default_review_apply_dest_for_review_csv():
review_csv = (
"redact/example_of_emails_sent_to_a_professor_before_applying/"
"output_redact/abc_review_file.csv"
)
assert _default_review_apply_dest_for_review_csv(review_csv) == (
"redact/example_of_emails_sent_to_a_professor_before_applying/"
"review/output_review_final"
)
def test_ensure_workspace_output_dir_repairs_pdf_dest(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
session = tmp_path / "sess"
pdf_name = "example_of_emails_sent_to_a_professor_before_applying.pdf"
pdf = session / pdf_name
pdf.parent.mkdir(parents=True)
pdf.write_bytes(b"%PDF-1.4")
review_csv = (
"redact/example_of_emails_sent_to_a_professor_before_applying/"
"output_redact/abc_review_file.csv"
)
dest = _ensure_workspace_output_dir(
"sess",
pdf_name,
pdf_relative_path=pdf_name,
review_csv_relative_path=review_csv,
default_for="review_apply",
)
assert (
dest
== (
session
/ "redact"
/ "example_of_emails_sent_to_a_professor_before_applying"
/ "review"
/ "output_review_final"
).resolve()
)
assert dest.is_dir()
def test_parse_review_apply_repairs_dest_dir_as_pdf(tmp_path, monkeypatch):
"""Model often puts output_review_final in the PDF slot near the end of a run."""
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
session = tmp_path / "sess"
session.mkdir()
(session / "source.pdf").write_bytes(b"%PDF-1.4")
review = session / "redact" / "source" / "output_redact" / "source_review_file.csv"
review.parent.mkdir(parents=True)
review.write_text("page,text\n1,a\n", encoding="utf-8-sig")
_remember_session_artifacts(
"sess",
pdf_relative_path="source.pdf",
review_csv_relative_path=str(review.relative_to(session)).replace("\\", "/"),
)
pdf_rel, review_rel, dest_rel = _parse_review_apply_tool_input(
"output_review_final",
str(review.relative_to(session)).replace("\\", "/"),
"",
session_hash="sess",
)
assert pdf_rel == "source.pdf"
assert review_rel.endswith("source_review_file.csv")
assert "output_review_final" in dest_rel.replace("\\", "/")
def test_run_review_apply_dest_as_pdf_returns_json_error(tmp_path, monkeypatch):
"""Uncaught FileNotFoundError used to crash the LangGraph tools node."""
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
reset_langgraph_tool_session_state("sess")
session = tmp_path / "sess"
session.mkdir()
# No source PDF — repair cannot autodiscover; must return JSON error, not raise.
review = session / "doc_review_file.csv"
review.write_text("page,text\n1,a\n", encoding="utf-8-sig")
out = run_review_apply(
"output_review_final",
"doc_review_file.csv",
"output_review_final",
session_hash="sess",
)
data = json.loads(out)
assert "error" in data
assert "fix_example" in data
def test_run_review_apply_repairs_pdf_dest(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
session = tmp_path / "sess"
pdf_name = "example_of_emails_sent_to_a_professor_before_applying.pdf"
pdf = session / pdf_name
review_csv = (
session
/ "redact"
/ "example_of_emails_sent_to_a_professor_before_applying"
/ "output_redact"
/ "abc_review_file.csv"
)
pdf.parent.mkdir(parents=True)
pdf.write_bytes(b"%PDF-1.4")
review_csv.parent.mkdir(parents=True)
review_csv.write_text("page,text,label\n1,foo,REDACT\n", encoding="utf-8-sig")
captured: dict[str, Any] = {}
class _FakeClient:
def predict(self, **kwargs):
captured["kwargs"] = kwargs
return (["C:/server/out/final_redacted.pdf"], "ok")
monkeypatch.setattr(
"redaction_langgraph.tools.make_redaction_client",
lambda: _FakeClient(),
)
monkeypatch.setattr(
"redaction_langgraph.tools.fetch_redaction_files",
lambda paths, dest: [dest / "final_redacted.pdf"],
)
out = run_review_apply(
pdf_name,
str(review_csv.relative_to(session)).replace("\\", "/"),
pdf_name,
session_hash="sess",
)
data = json.loads(out)
assert "error" not in data
assert captured["kwargs"]["pdf_file"] is not None
assert captured["kwargs"]["review_csv_file"] is not None
assert data["saved_paths"][0].endswith("final_redacted.pdf")
def test_resolve_optional_redacted_pdf_rejects_review_csv(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
session = tmp_path / "sess"
review = session / "doc_review_file.csv"
review.parent.mkdir(parents=True)
review.write_text("id,page\n", encoding="utf-8-sig")
try:
_resolve_optional_redacted_pdf(
"sess",
"doc_review_file.csv",
review_csv=review,
)
raise AssertionError("expected ValueError")
except ValueError as exc:
assert "must be a PDF" in str(exc)
def test_run_verify_coverage_rejects_csv_as_redacted_pdf(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path))
monkeypatch.setenv("AGENT_SESSION_WORKSPACE", "1")
session = tmp_path / "sess"
out = session / "output_redact"
out.mkdir(parents=True)
review = out / "doc_review_file.csv"
review.write_text("id,page,text\n1,1,hello\n", encoding="utf-8-sig")
words = out / "doc_ocr_results_with_words_local_ocr.csv"
words.write_text("word_text,page\nhello,1\n", encoding="utf-8-sig")
result = run_verify_coverage(
"output_redact/doc_review_file.csv",
session_hash="sess",
redacted_pdf_relative_path="output_redact/doc_review_file.csv",
)
data = json.loads(result)
assert "error" in data
assert "PDF" in data["error"]
assert "hint" in data