| """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 ( |
| _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 ( |
| 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" |
| ) |
| |
| |
| 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) |
| |
| 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 |
| |
| 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") |
| |
| 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") |
| |
| 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() |
| |
| 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 |
|
|