From 5567133222fac95098cd0afcd67604215e054733 Mon Sep 17 00:00:00 2001 From: mikec-ai Date: Wed, 7 Oct 2026 23:40:40 -0400 Subject: [PATCH 1/3] refactor: separate document content processing from import workflow --- documentation/DOCUMENT_PROCESSING.md | 60 ++++++++++++++ documentation/IMPORT_RULES.md | 1 + documentation/README.md | 1 + .../document-processing-1058/README.md | 15 ++++ .../document-processing-1058/import-smoke.jpg | Bin 0 -> 65783 bytes nofos/nofos/document_processing.py | 56 +++++++++++++ .../tests_nofos/test_document_processing.py | 76 ++++++++++++++++++ .../test_import_characterization.py | 12 ++- nofos/nofos/views.py | 37 +++------ 9 files changed, 232 insertions(+), 26 deletions(-) create mode 100644 documentation/DOCUMENT_PROCESSING.md create mode 100644 documentation/review-evidence/document-processing-1058/README.md create mode 100644 documentation/review-evidence/document-processing-1058/import-smoke.jpg create mode 100644 nofos/nofos/document_processing.py create mode 100644 nofos/nofos/tests_nofos/test_document_processing.py diff --git a/documentation/DOCUMENT_PROCESSING.md b/documentation/DOCUMENT_PROCESSING.md new file mode 100644 index 00000000..335b80cd --- /dev/null +++ b/documentation/DOCUMENT_PROCESSING.md @@ -0,0 +1,60 @@ +# Shared document processing + +`nofos/document_processing.py::process_document_content` accepts HTML text and +returns existing mutable soup, section/subsection dictionaries and extracted +instruction tables. An internal caller can use it without a request, upload, +user or document database record. It does not persist documents or return web +responses. These parser-specific results are not a public interchange schema. + +## Responsibilities + +The shared sequence preserves character/link replacement, HTML parsing, the +first Before you begin removal, heading-level resolution, ordered NOFO +normalization, then section/subsection parsing. Metadata remains in the soup +for later inference. No transformation rule or order changes. + +Builder, Composer and Compare use it through `BaseNofoImportView`. The view +retains upload conversion, filename handling, warning counts, permissions, +error presentation and logging. Word conversion still uses Mammoth, its style +map, warning filtering and strict mode. HTML callers need no uploaded file. + +The optional section parser preserves the existing view extension point and +receives only soup and heading level. Composer instruction attachment stays +in the parsing error scope. Step 3 and key-date postprocessing stay outside it. +Metadata inference, persistence, transactions, auditing, readability snapshots +and reimport confirmation remain in their existing owners. + +Exceptions propagate unchanged, including `ValidationError` with `no_sections`. +The application maps failures to existing pages and error codes. Conversion +warnings remain separate from content results; HTML uploads still report zero +Mammoth warnings. Results retain mutable identity for consumer hooks. + +## Portability limits + +This is an internal separation, not a package, service or framework migration. +BeautifulSoup tags and dictionaries remain parser-specific. Normalization +remains NOFO-specific, including Word-derived table/callout/heading conventions. +It does not promise compatibility with every editor's HTML or form data. + +The imported `nofo.py` still loads Django models, settings, Constance and +application utilities. Django initialization is required even though processing +performs no document persistence. Existing validation exceptions and heading +limits remain application dependencies. Later extraction would need to isolate +these dependencies and translate errors at the application boundary. + +With DEBUG enabled, `process_nofo_html` still writes a debug HTML snapshot under +the fixtures directory. Disable DEBUG for sensitive content or processing that +must not write that snapshot. Tests run with DEBUG disabled. No new side effects +or configuration switches are introduced. + +Dependencies include BeautifulSoup and existing HTML/Markdown helpers and +configuration. The separate Word adapter also needs Mammoth, its style map and +document transform. Output generation remains separate and is not duplicated. + +## Verification + +Direct synthetic tests need no request, upload or database records. They compare +results against the pre-extraction sequence and verify metadata, instructions, +extension-point identity and failure codes. Characterization tests retain +transformation order and hook error ownership. Real synthetic imports exercise +all three consumers. Full CI and a local UI smoke check complete delivery. diff --git a/documentation/IMPORT_RULES.md b/documentation/IMPORT_RULES.md index 38c22b90..ae2b00f4 100644 --- a/documentation/IMPORT_RULES.md +++ b/documentation/IMPORT_RULES.md @@ -7,6 +7,7 @@ This document catalogs every automatic content rule the NOFO Builder applies whe **Not to be confused with import error *codes*.** The `IMPORT-NNN` IDs here are numbered rule identifiers for content transformations. The `IMPORT-NAME` strings a user sees when an import is blocked (`IMPORT-NO-SECTIONS`, `IMPORT-OPDIV-BLANK`, and so on) are a separate namespace, catalogued in [`IMPORT_ERROR_CODES.md`](IMPORT_ERROR_CODES.md). A rule here can be the *reason* an error code fires; they are not the same registry. **Source files covered by this document:** +- `nofos/nofos/document_processing.py` — shared content orchestration; rule implementations remain in their existing modules, with unchanged order and behavior. See [DOCUMENT_PROCESSING.md](DOCUMENT_PROCESSING.md). - `nofos/nofos/nofo.py` — `process_nofo_html()` and the ~20 cleanup passes it runs, plus sectioning/subsectioning and metadata-suggestion logic - `nofos/nofos/utils.py` — `style_map_manager`, the Mammoth DOCX→HTML style-name map - `nofos/nofos/import_transforms.py` — pre-Mammoth DOCX document transform (`transform_word_document`) diff --git a/documentation/README.md b/documentation/README.md index a40653b0..36d355b0 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -14,6 +14,7 @@ decision records belong in `adr/`; PR-specific screenshots and validation notes - [IMPORT_ERROR_CODES.md](IMPORT_ERROR_CODES.md) — What each blocking import error code (`IMPORT-NO-SECTIONS`, `IMPORT-OPDIV-BLANK`, …) means and what to tell someone who hits one - [IMPORT_RULES.md](IMPORT_RULES.md) — Every automatic content rule applied when a NOFO is imported (footnote/endnote handling, list/table repair, metadata suggestion, etc.) - [endnote-import.md](endnote-import.md) — How Word imports preserve native notes and link manually authored bracketed references +- [DOCUMENT_PROCESSING.md](DOCUMENT_PROCESSING.md) — Shared content-processing boundary, application responsibilities and remaining portability limits - [READABILITY_METRICS.md](READABILITY_METRICS.md) — The `hhs-nofo-metrics` integration and source contract; when readability snapshots are saved (by a person, or automatically on import, re-import and Download PDF); saved readability history, deletion, archiving, and duplication behavior - [UI_PATTERNS.md](UI_PATTERNS.md) — Reusable interface patterns, including shared file-upload error states and loading progress modals - [TABLES.md](TABLES.md) — Automatic table styling: size classes, captions, points columns, import-time width classes, and how users override them diff --git a/documentation/review-evidence/document-processing-1058/README.md b/documentation/review-evidence/document-processing-1058/README.md new file mode 100644 index 00000000..7dfd3467 --- /dev/null +++ b/documentation/review-evidence/document-processing-1058/README.md @@ -0,0 +1,15 @@ +# Document processing smoke check + +Local Chrome check of the #1058 refactor, using an isolated SQLite database and +synthetic HTML. No production document or credentials were used. DEBUG was off. + +The existing import form accepted the synthetic HTML, advanced to naming, saved +the NOFO and displayed its edit page. Verified both step headings, Summary, +applicant prose, the Funding amounts caption, the table's zero value, and the +ordered-list content. Clicking Read eligibility note reached `#note-1`. + +![Imported synthetic content](import-smoke.jpg) + +This is a content-processing smoke check, not a new Word conversion test or PDF +export check. Automated import/reimport, Composer and Compare tests cover the +other consumers and the unchanged Word conversion path. diff --git a/documentation/review-evidence/document-processing-1058/import-smoke.jpg b/documentation/review-evidence/document-processing-1058/import-smoke.jpg new file mode 100644 index 0000000000000000000000000000000000000000..382b4faf7c6c63d094a19437ce164809b711e40a GIT binary patch literal 65783 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 literal 0 HcmV?d00001 diff --git a/nofos/nofos/document_processing.py b/nofos/nofos/document_processing.py new file mode 100644 index 00000000..8b74645b --- /dev/null +++ b/nofos/nofos/document_processing.py @@ -0,0 +1,56 @@ +"""Internal content processing, separate from upload and persistence workflows. + +This reuses Builder's NOFO-specific rules and parser objects, not a portable +interchange schema. See documentation/DOCUMENT_PROCESSING.md for dependencies. +""" + +from dataclasses import dataclass + +from bs4 import BeautifulSoup +from django.core.exceptions import ValidationError + +from .nofo import ( + decompose_before_you_begin_section, + get_sections_from_soup, + get_subsections_from_sections, + process_nofo_html, + replace_chars, + replace_links, + resolve_section_heading_level, +) + + +@dataclass +class ProcessedDocumentContent: + """Existing mutable parser results; no copies or persistence are performed.""" + + soup: BeautifulSoup + sections: list + instructions_tables: list + + +def get_sections_and_subsections_from_soup(soup, top_heading_level): + """Extract content dictionaries, retaining the existing validation code.""" + sections = get_sections_from_soup(soup, top_heading_level) + if not len(sections): + raise ValidationError("That file does not contain a NOFO.", code="no_sections") + return get_subsections_from_sections(sections, top_heading_level) + + +def process_document_content(html, *, section_parser=None): + """Normalize HTML and parse sections without a request, upload or ORM record. + + ``section_parser`` preserves the application's existing overridable section + parser. Instruction attachment and consumer postprocessing stay with callers. + Exceptions propagate unchanged for the caller to present or handle. + """ + cleaned_content = replace_links(replace_chars(html)) + soup = BeautifulSoup(cleaned_content, "html.parser") + # Preserve the pre-pass before selecting the main section heading level. + decompose_before_you_begin_section(soup) + top_heading_level = resolve_section_heading_level(soup) + soup, instructions_tables = process_nofo_html(soup, top_heading_level) + if section_parser is None: + section_parser = get_sections_and_subsections_from_soup + sections = section_parser(soup, top_heading_level) + return ProcessedDocumentContent(soup, sections, instructions_tables) diff --git a/nofos/nofos/tests_nofos/test_document_processing.py b/nofos/nofos/tests_nofos/test_document_processing.py new file mode 100644 index 00000000..50d53be2 --- /dev/null +++ b/nofos/nofos/tests_nofos/test_document_processing.py @@ -0,0 +1,76 @@ +"""Direct content callers need no request, upload, user or document record.""" + +from bs4 import BeautifulSoup +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase, override_settings + +from nofos.document_processing import process_document_content +from nofos.nofo import ( + decompose_before_you_begin_section, + get_sections_from_soup, + get_subsections_from_sections, + process_nofo_html, + replace_chars, + replace_links, + resolve_section_heading_level, +) + + +@override_settings(DEBUG=False) +class DocumentProcessingTests(SimpleTestCase): + def test_direct_content_preserves_metadata_and_excludes_redundant_section(self): + result = process_document_content( + "

Before you begin

Redundant text.

" + "

Opdiv: CDC

Opportunity

" + "

Summary

Public agencies may apply.

" + ) + self.assertIn("Opdiv: CDC", str(result.soup)) + self.assertNotIn("Redundant text.", str(result.soup)) + self.assertEqual([s["name"] for s in result.sections], ["Opportunity"]) + self.assertNotIn("Opdiv: CDC", str(result.sections)) + self.assertIn("Public agencies may apply.", str(result.sections)) + + def test_existing_processing_sequence_has_identical_results(self): + # Pre-extraction sequence provides a concrete before/after oracle. + shapes = ( + "

Opportunity

Eligibility

Public agencies.

", + "

Opportunity

Awards

Funding
ProgramAmount
Training0
", + '

Opportunity

Summary

Agency logo

Read note

  1. Source
', + "

Opdiv: CDC

Opportunity

Summary

" + "

Instructions for NOFO writers

Keep this instruction.

Applicant text.

", + ) + for html in shapes: + with self.subTest(html=html): + before = BeautifulSoup( + replace_links(replace_chars(html)), "html.parser" + ) + decompose_before_you_begin_section(before) + level = resolve_section_heading_level(before) + before, instructions = process_nofo_html(before, level) + sections = get_subsections_from_sections( + get_sections_from_soup(before, level), level + ) + after = process_document_content(html) + self.assertEqual(str(after.soup), str(before)) + self.assertEqual(str(after.sections), str(sections)) + self.assertEqual(str(after.instructions_tables), str(instructions)) + + def test_section_parser_extension_keeps_mutable_identity(self): + sections = [{"name": "Consumer result", "subsections": []}] + calls = [] + + def section_parser(soup, level): + calls.append((soup, level)) + return sections + + result = process_document_content( + "

Opportunity

", section_parser=section_parser + ) + self.assertIs(result.sections, sections) + self.assertIs(calls[0][0], result.soup) + self.assertEqual(calls[0][1], "h1") + + def test_no_sections_failure_propagates_without_web_presentation(self): + with self.assertRaises(ValidationError) as error: + process_document_content("

Only body text.

") + self.assertEqual(error.exception.code, "no_sections") diff --git a/nofos/nofos/tests_nofos/test_import_characterization.py b/nofos/nofos/tests_nofos/test_import_characterization.py index fabd8cbe..4caefb80 100644 --- a/nofos/nofos/tests_nofos/test_import_characterization.py +++ b/nofos/nofos/tests_nofos/test_import_characterization.py @@ -90,8 +90,18 @@ def instruction_hook(**kwargs): } with ExitStack() as stack: for function, (name, result) in transforms.items(): + module = ( + "nofos.views." + if function + in { + "parse_uploaded_file_as_html_string", + "add_final_subsection_to_step_3", + "add_line_breaks_to_key_dates_values", + } + else "nofos.document_processing." + ) stack.enter_context( - patch("nofos.views." + function, side_effect=step(name, result)) + patch(module + function, side_effect=step(name, result)) ) stack.enter_context( patch.object( diff --git a/nofos/nofos/views.py b/nofos/nofos/views.py index 43b95ed1..be815591 100644 --- a/nofos/nofos/views.py +++ b/nofos/nofos/views.py @@ -54,6 +54,10 @@ get_audit_events_for_nofo, safe_get_changed_fields, ) +from .document_processing import ( + get_sections_and_subsections_from_soup, + process_document_content, +) from .forms import ( NIH_ALLOWED_CHOICES, NIH_THEME_DEFAULTS, @@ -123,7 +127,6 @@ count_page_breaks_nofo, count_page_breaks_subsection, create_nofo, - decompose_before_you_begin_section, extract_page_break_context, find_broken_links, find_endnote_issues, @@ -135,21 +138,16 @@ find_subsections_with_nofo_field_value, get_cover_image, get_nofo_action_links, - get_sections_from_soup, get_side_nav_links, get_subsection_action_availability, - get_subsections_from_sections, modifications_update_announcement_text, nofo_has_appendix_section, nofo_has_end_notes_section, overwrite_nofo, parse_uploaded_file_as_html_string, preserve_subsection_metadata, - process_nofo_html, remove_cover_image_from_s3, remove_page_breaks_from_subsection, - replace_chars, - replace_links, replace_value_in_subsections, resolve_section_heading_level, restore_subsection_metadata, @@ -821,23 +819,17 @@ def post(self, request, *args, **kwargs): uploaded_file ) - # 3. Clean/transform HTML - cleaned_content = replace_links(replace_chars(file_content)) - soup = BeautifulSoup(cleaned_content, "html.parser") - # Remove this known redundant section before it can affect which - # heading level Builder treats as the document's main sections. - decompose_before_you_begin_section(soup) - top_heading_level = resolve_section_heading_level(soup) - soup, instructions_tables = process_nofo_html(soup, top_heading_level) - - # 4. Build sections and subsections as python dicts - sections = self.get_sections_and_subsections_from_soup( - soup, top_heading_level + # 3–4. Shared content processing, independent of the uploaded file. + content = process_document_content( + file_content, + section_parser=self.get_sections_and_subsections_from_soup, ) + soup = content.soup + sections = content.sections # 5. Add instructions to subsections (only implemented in Composer) self.add_instructions_to_subsections( - sections=sections, instructions_tables=instructions_tables + sections=sections, instructions_tables=content.instructions_tables ) except ValidationError as e: @@ -939,12 +931,7 @@ def get_sections_and_subsections_from_soup(soup, top_heading_level): Parse a soup object to extract sections and subsections. Raise ValidationError if no sections are found. """ - sections = get_sections_from_soup(soup, top_heading_level) - if not len(sections): - raise ValidationError( - "That file does not contain a NOFO.", code="no_sections" - ) - return get_subsections_from_sections(sections, top_heading_level) + return get_sections_and_subsections_from_soup(soup, top_heading_level) def add_instructions_to_subsections(self, *, sections, instructions_tables) -> None: """ From 9efe79e84c276d78bdf292d062d218bd631d8cff Mon Sep 17 00:00:00 2001 From: mikec-ai Date: Wed, 7 Oct 2026 23:40:40 -0400 Subject: [PATCH 2/3] refactor: separate document content processing from import workflow --- documentation/DOCUMENT_PROCESSING.md | 60 ++++++++++++++ documentation/IMPORT_RULES.md | 1 + documentation/README.md | 1 + .../document-processing-1058/README.md | 15 ++++ .../document-processing-1058/import-smoke.jpg | Bin 0 -> 65783 bytes nofos/nofos/document_processing.py | 56 +++++++++++++ .../tests_nofos/test_document_processing.py | 76 ++++++++++++++++++ .../test_import_characterization.py | 12 ++- nofos/nofos/views.py | 37 +++------ 9 files changed, 232 insertions(+), 26 deletions(-) create mode 100644 documentation/DOCUMENT_PROCESSING.md create mode 100644 documentation/review-evidence/document-processing-1058/README.md create mode 100644 documentation/review-evidence/document-processing-1058/import-smoke.jpg create mode 100644 nofos/nofos/document_processing.py create mode 100644 nofos/nofos/tests_nofos/test_document_processing.py diff --git a/documentation/DOCUMENT_PROCESSING.md b/documentation/DOCUMENT_PROCESSING.md new file mode 100644 index 00000000..335b80cd --- /dev/null +++ b/documentation/DOCUMENT_PROCESSING.md @@ -0,0 +1,60 @@ +# Shared document processing + +`nofos/document_processing.py::process_document_content` accepts HTML text and +returns existing mutable soup, section/subsection dictionaries and extracted +instruction tables. An internal caller can use it without a request, upload, +user or document database record. It does not persist documents or return web +responses. These parser-specific results are not a public interchange schema. + +## Responsibilities + +The shared sequence preserves character/link replacement, HTML parsing, the +first Before you begin removal, heading-level resolution, ordered NOFO +normalization, then section/subsection parsing. Metadata remains in the soup +for later inference. No transformation rule or order changes. + +Builder, Composer and Compare use it through `BaseNofoImportView`. The view +retains upload conversion, filename handling, warning counts, permissions, +error presentation and logging. Word conversion still uses Mammoth, its style +map, warning filtering and strict mode. HTML callers need no uploaded file. + +The optional section parser preserves the existing view extension point and +receives only soup and heading level. Composer instruction attachment stays +in the parsing error scope. Step 3 and key-date postprocessing stay outside it. +Metadata inference, persistence, transactions, auditing, readability snapshots +and reimport confirmation remain in their existing owners. + +Exceptions propagate unchanged, including `ValidationError` with `no_sections`. +The application maps failures to existing pages and error codes. Conversion +warnings remain separate from content results; HTML uploads still report zero +Mammoth warnings. Results retain mutable identity for consumer hooks. + +## Portability limits + +This is an internal separation, not a package, service or framework migration. +BeautifulSoup tags and dictionaries remain parser-specific. Normalization +remains NOFO-specific, including Word-derived table/callout/heading conventions. +It does not promise compatibility with every editor's HTML or form data. + +The imported `nofo.py` still loads Django models, settings, Constance and +application utilities. Django initialization is required even though processing +performs no document persistence. Existing validation exceptions and heading +limits remain application dependencies. Later extraction would need to isolate +these dependencies and translate errors at the application boundary. + +With DEBUG enabled, `process_nofo_html` still writes a debug HTML snapshot under +the fixtures directory. Disable DEBUG for sensitive content or processing that +must not write that snapshot. Tests run with DEBUG disabled. No new side effects +or configuration switches are introduced. + +Dependencies include BeautifulSoup and existing HTML/Markdown helpers and +configuration. The separate Word adapter also needs Mammoth, its style map and +document transform. Output generation remains separate and is not duplicated. + +## Verification + +Direct synthetic tests need no request, upload or database records. They compare +results against the pre-extraction sequence and verify metadata, instructions, +extension-point identity and failure codes. Characterization tests retain +transformation order and hook error ownership. Real synthetic imports exercise +all three consumers. Full CI and a local UI smoke check complete delivery. diff --git a/documentation/IMPORT_RULES.md b/documentation/IMPORT_RULES.md index 38c22b90..ae2b00f4 100644 --- a/documentation/IMPORT_RULES.md +++ b/documentation/IMPORT_RULES.md @@ -7,6 +7,7 @@ This document catalogs every automatic content rule the NOFO Builder applies whe **Not to be confused with import error *codes*.** The `IMPORT-NNN` IDs here are numbered rule identifiers for content transformations. The `IMPORT-NAME` strings a user sees when an import is blocked (`IMPORT-NO-SECTIONS`, `IMPORT-OPDIV-BLANK`, and so on) are a separate namespace, catalogued in [`IMPORT_ERROR_CODES.md`](IMPORT_ERROR_CODES.md). A rule here can be the *reason* an error code fires; they are not the same registry. **Source files covered by this document:** +- `nofos/nofos/document_processing.py` — shared content orchestration; rule implementations remain in their existing modules, with unchanged order and behavior. See [DOCUMENT_PROCESSING.md](DOCUMENT_PROCESSING.md). - `nofos/nofos/nofo.py` — `process_nofo_html()` and the ~20 cleanup passes it runs, plus sectioning/subsectioning and metadata-suggestion logic - `nofos/nofos/utils.py` — `style_map_manager`, the Mammoth DOCX→HTML style-name map - `nofos/nofos/import_transforms.py` — pre-Mammoth DOCX document transform (`transform_word_document`) diff --git a/documentation/README.md b/documentation/README.md index c140af47..097f24ac 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -14,6 +14,7 @@ decision records belong in `adr/`; PR-specific screenshots and validation notes - [IMPORT_ERROR_CODES.md](IMPORT_ERROR_CODES.md) — What each blocking import error code (`IMPORT-NO-SECTIONS`, `IMPORT-OPDIV-BLANK`, …) means and what to tell someone who hits one - [IMPORT_RULES.md](IMPORT_RULES.md) — Every automatic content rule applied when a NOFO is imported (footnote/endnote handling, list/table repair, metadata suggestion, etc.) - [endnote-import.md](endnote-import.md) — How Word imports preserve native notes and link manually authored bracketed references +- [DOCUMENT_PROCESSING.md](DOCUMENT_PROCESSING.md) — Shared content-processing boundary, application responsibilities and remaining portability limits - [READABILITY_METRICS.md](READABILITY_METRICS.md) — The `hhs-nofo-metrics` integration and source contract; when readability snapshots are saved (by a person, or automatically on import, re-import and Download PDF); saved readability history, deletion, archiving, and duplication behavior - [UI_PATTERNS.md](UI_PATTERNS.md) — Reusable interface patterns, including shared file-upload error states, loading progress modals, and readability reliability alerts - [TABLES.md](TABLES.md) — Automatic table styling: size classes, captions, points columns, import-time width classes, and how users override them diff --git a/documentation/review-evidence/document-processing-1058/README.md b/documentation/review-evidence/document-processing-1058/README.md new file mode 100644 index 00000000..7dfd3467 --- /dev/null +++ b/documentation/review-evidence/document-processing-1058/README.md @@ -0,0 +1,15 @@ +# Document processing smoke check + +Local Chrome check of the #1058 refactor, using an isolated SQLite database and +synthetic HTML. No production document or credentials were used. DEBUG was off. + +The existing import form accepted the synthetic HTML, advanced to naming, saved +the NOFO and displayed its edit page. Verified both step headings, Summary, +applicant prose, the Funding amounts caption, the table's zero value, and the +ordered-list content. Clicking Read eligibility note reached `#note-1`. + +![Imported synthetic content](import-smoke.jpg) + +This is a content-processing smoke check, not a new Word conversion test or PDF +export check. Automated import/reimport, Composer and Compare tests cover the +other consumers and the unchanged Word conversion path. diff --git a/documentation/review-evidence/document-processing-1058/import-smoke.jpg b/documentation/review-evidence/document-processing-1058/import-smoke.jpg new file mode 100644 index 0000000000000000000000000000000000000000..382b4faf7c6c63d094a19437ce164809b711e40a GIT binary patch literal 65783 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 literal 0 HcmV?d00001 diff --git a/nofos/nofos/document_processing.py b/nofos/nofos/document_processing.py new file mode 100644 index 00000000..8b74645b --- /dev/null +++ b/nofos/nofos/document_processing.py @@ -0,0 +1,56 @@ +"""Internal content processing, separate from upload and persistence workflows. + +This reuses Builder's NOFO-specific rules and parser objects, not a portable +interchange schema. See documentation/DOCUMENT_PROCESSING.md for dependencies. +""" + +from dataclasses import dataclass + +from bs4 import BeautifulSoup +from django.core.exceptions import ValidationError + +from .nofo import ( + decompose_before_you_begin_section, + get_sections_from_soup, + get_subsections_from_sections, + process_nofo_html, + replace_chars, + replace_links, + resolve_section_heading_level, +) + + +@dataclass +class ProcessedDocumentContent: + """Existing mutable parser results; no copies or persistence are performed.""" + + soup: BeautifulSoup + sections: list + instructions_tables: list + + +def get_sections_and_subsections_from_soup(soup, top_heading_level): + """Extract content dictionaries, retaining the existing validation code.""" + sections = get_sections_from_soup(soup, top_heading_level) + if not len(sections): + raise ValidationError("That file does not contain a NOFO.", code="no_sections") + return get_subsections_from_sections(sections, top_heading_level) + + +def process_document_content(html, *, section_parser=None): + """Normalize HTML and parse sections without a request, upload or ORM record. + + ``section_parser`` preserves the application's existing overridable section + parser. Instruction attachment and consumer postprocessing stay with callers. + Exceptions propagate unchanged for the caller to present or handle. + """ + cleaned_content = replace_links(replace_chars(html)) + soup = BeautifulSoup(cleaned_content, "html.parser") + # Preserve the pre-pass before selecting the main section heading level. + decompose_before_you_begin_section(soup) + top_heading_level = resolve_section_heading_level(soup) + soup, instructions_tables = process_nofo_html(soup, top_heading_level) + if section_parser is None: + section_parser = get_sections_and_subsections_from_soup + sections = section_parser(soup, top_heading_level) + return ProcessedDocumentContent(soup, sections, instructions_tables) diff --git a/nofos/nofos/tests_nofos/test_document_processing.py b/nofos/nofos/tests_nofos/test_document_processing.py new file mode 100644 index 00000000..50d53be2 --- /dev/null +++ b/nofos/nofos/tests_nofos/test_document_processing.py @@ -0,0 +1,76 @@ +"""Direct content callers need no request, upload, user or document record.""" + +from bs4 import BeautifulSoup +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase, override_settings + +from nofos.document_processing import process_document_content +from nofos.nofo import ( + decompose_before_you_begin_section, + get_sections_from_soup, + get_subsections_from_sections, + process_nofo_html, + replace_chars, + replace_links, + resolve_section_heading_level, +) + + +@override_settings(DEBUG=False) +class DocumentProcessingTests(SimpleTestCase): + def test_direct_content_preserves_metadata_and_excludes_redundant_section(self): + result = process_document_content( + "

Before you begin

Redundant text.

" + "

Opdiv: CDC

Opportunity

" + "

Summary

Public agencies may apply.

" + ) + self.assertIn("Opdiv: CDC", str(result.soup)) + self.assertNotIn("Redundant text.", str(result.soup)) + self.assertEqual([s["name"] for s in result.sections], ["Opportunity"]) + self.assertNotIn("Opdiv: CDC", str(result.sections)) + self.assertIn("Public agencies may apply.", str(result.sections)) + + def test_existing_processing_sequence_has_identical_results(self): + # Pre-extraction sequence provides a concrete before/after oracle. + shapes = ( + "

Opportunity

Eligibility

Public agencies.

", + "

Opportunity

Awards

Funding
ProgramAmount
Training0
", + '

Opportunity

Summary

Agency logo

Read note

  1. Source
', + "

Opdiv: CDC

Opportunity

Summary

" + "

Instructions for NOFO writers

Keep this instruction.

Applicant text.

", + ) + for html in shapes: + with self.subTest(html=html): + before = BeautifulSoup( + replace_links(replace_chars(html)), "html.parser" + ) + decompose_before_you_begin_section(before) + level = resolve_section_heading_level(before) + before, instructions = process_nofo_html(before, level) + sections = get_subsections_from_sections( + get_sections_from_soup(before, level), level + ) + after = process_document_content(html) + self.assertEqual(str(after.soup), str(before)) + self.assertEqual(str(after.sections), str(sections)) + self.assertEqual(str(after.instructions_tables), str(instructions)) + + def test_section_parser_extension_keeps_mutable_identity(self): + sections = [{"name": "Consumer result", "subsections": []}] + calls = [] + + def section_parser(soup, level): + calls.append((soup, level)) + return sections + + result = process_document_content( + "

Opportunity

", section_parser=section_parser + ) + self.assertIs(result.sections, sections) + self.assertIs(calls[0][0], result.soup) + self.assertEqual(calls[0][1], "h1") + + def test_no_sections_failure_propagates_without_web_presentation(self): + with self.assertRaises(ValidationError) as error: + process_document_content("

Only body text.

") + self.assertEqual(error.exception.code, "no_sections") diff --git a/nofos/nofos/tests_nofos/test_import_characterization.py b/nofos/nofos/tests_nofos/test_import_characterization.py index fabd8cbe..4caefb80 100644 --- a/nofos/nofos/tests_nofos/test_import_characterization.py +++ b/nofos/nofos/tests_nofos/test_import_characterization.py @@ -90,8 +90,18 @@ def instruction_hook(**kwargs): } with ExitStack() as stack: for function, (name, result) in transforms.items(): + module = ( + "nofos.views." + if function + in { + "parse_uploaded_file_as_html_string", + "add_final_subsection_to_step_3", + "add_line_breaks_to_key_dates_values", + } + else "nofos.document_processing." + ) stack.enter_context( - patch("nofos.views." + function, side_effect=step(name, result)) + patch(module + function, side_effect=step(name, result)) ) stack.enter_context( patch.object( diff --git a/nofos/nofos/views.py b/nofos/nofos/views.py index 43b95ed1..be815591 100644 --- a/nofos/nofos/views.py +++ b/nofos/nofos/views.py @@ -54,6 +54,10 @@ get_audit_events_for_nofo, safe_get_changed_fields, ) +from .document_processing import ( + get_sections_and_subsections_from_soup, + process_document_content, +) from .forms import ( NIH_ALLOWED_CHOICES, NIH_THEME_DEFAULTS, @@ -123,7 +127,6 @@ count_page_breaks_nofo, count_page_breaks_subsection, create_nofo, - decompose_before_you_begin_section, extract_page_break_context, find_broken_links, find_endnote_issues, @@ -135,21 +138,16 @@ find_subsections_with_nofo_field_value, get_cover_image, get_nofo_action_links, - get_sections_from_soup, get_side_nav_links, get_subsection_action_availability, - get_subsections_from_sections, modifications_update_announcement_text, nofo_has_appendix_section, nofo_has_end_notes_section, overwrite_nofo, parse_uploaded_file_as_html_string, preserve_subsection_metadata, - process_nofo_html, remove_cover_image_from_s3, remove_page_breaks_from_subsection, - replace_chars, - replace_links, replace_value_in_subsections, resolve_section_heading_level, restore_subsection_metadata, @@ -821,23 +819,17 @@ def post(self, request, *args, **kwargs): uploaded_file ) - # 3. Clean/transform HTML - cleaned_content = replace_links(replace_chars(file_content)) - soup = BeautifulSoup(cleaned_content, "html.parser") - # Remove this known redundant section before it can affect which - # heading level Builder treats as the document's main sections. - decompose_before_you_begin_section(soup) - top_heading_level = resolve_section_heading_level(soup) - soup, instructions_tables = process_nofo_html(soup, top_heading_level) - - # 4. Build sections and subsections as python dicts - sections = self.get_sections_and_subsections_from_soup( - soup, top_heading_level + # 3–4. Shared content processing, independent of the uploaded file. + content = process_document_content( + file_content, + section_parser=self.get_sections_and_subsections_from_soup, ) + soup = content.soup + sections = content.sections # 5. Add instructions to subsections (only implemented in Composer) self.add_instructions_to_subsections( - sections=sections, instructions_tables=instructions_tables + sections=sections, instructions_tables=content.instructions_tables ) except ValidationError as e: @@ -939,12 +931,7 @@ def get_sections_and_subsections_from_soup(soup, top_heading_level): Parse a soup object to extract sections and subsections. Raise ValidationError if no sections are found. """ - sections = get_sections_from_soup(soup, top_heading_level) - if not len(sections): - raise ValidationError( - "That file does not contain a NOFO.", code="no_sections" - ) - return get_subsections_from_sections(sections, top_heading_level) + return get_sections_and_subsections_from_soup(soup, top_heading_level) def add_instructions_to_subsections(self, *, sections, instructions_tables) -> None: """ From b4b2d6bb08487eff219f7edf129d34983ec2da67 Mon Sep 17 00:00:00 2001 From: Benjamin Brasso Date: Thu, 8 Oct 2026 12:38:35 -0400 Subject: [PATCH 3/3] docs: link shared processing guidance from contribution workflow --- documentation/CONTRIBUTION_WORKFLOW.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/CONTRIBUTION_WORKFLOW.md b/documentation/CONTRIBUTION_WORKFLOW.md index 044ea6e2..43a304b2 100644 --- a/documentation/CONTRIBUTION_WORKFLOW.md +++ b/documentation/CONTRIBUTION_WORKFLOW.md @@ -53,7 +53,7 @@ flowchart TD | --- | --- | | Create the issue | [Bug report](../.github/ISSUE_TEMPLATE/bug_report.md) and [feature request](../.github/ISSUE_TEMPLATE/feature_request.md) templates (GitHub offers them when you click **New issue**) | | Branch off `main` | [`DEPLOYMENT.md` § 1. Create a feature branch](../DEPLOYMENT.md#1-create-a-feature-branch) and [§ Branch Protection Rules](../DEPLOYMENT.md#branch-protection-rules) | -| Changing import behavior | [`IMPORT_RULES.md`](IMPORT_RULES.md) and [`DEPLOYMENT.md` § Updating Import Rules](../DEPLOYMENT.md#updating-import-rules) | +| Changing import behavior | [`IMPORT_RULES.md`](IMPORT_RULES.md), [`DOCUMENT_PROCESSING.md`](DOCUMENT_PROCESSING.md) for the shared processing boundary, and [`DEPLOYMENT.md` § Updating Import Rules](../DEPLOYMENT.md#updating-import-rules) | | Changing user-facing import errors | [`IMPORT_ERROR_CODES.md`](IMPORT_ERROR_CODES.md), and the "Copy rules" at the top of [`nofos/bloom_nofos/import_errors.py`](../nofos/bloom_nofos/import_errors.py) | | Adding or changing UI | [`UI_PATTERNS.md`](UI_PATTERNS.md): check this growing catalog for a relevant pattern and reuse its shared component. It is not a complete inventory of the site's UI; extend it when introducing another verified pattern. | | Updating docs | [`documentation/README.md`](README.md): docs go in `documentation/`, never a top-level `docs/`; ADRs go in [`adr/`](adr/README.md); PR screenshots go in `review-evidence/` |