diff --git a/.github/skills/extraction-run/SKILL.md b/.github/skills/extraction-run/SKILL.md index c2fafa8b8..8ed470f3e 100644 --- a/.github/skills/extraction-run/SKILL.md +++ b/.github/skills/extraction-run/SKILL.md @@ -166,14 +166,30 @@ A successful run produces these files under `out_dir`: | `ordinance_files/*.pdf` | Downloaded source documents | | `cleaned_text/*.txt` | Heuristic-filtered extracted text | | `jurisdiction_dbs/*.csv` | Per-jurisdiction raw extraction rows | -| `quantitative_ordinances.csv` | Final compiled numeric features | -| `qualitative_ordinances.csv` | Final compiled qualitative features | +| `ordinances.csv` | Final compiled features (quantitative and qualitative) | | `usage.json` | Per-jurisdiction LLM token and request counts | | `meta.json` | Run metadata (cost, timing, version) | Final CSV columns: `county`, `state`, `subdivision`, `jurisdiction_type`, `FIPS`, `feature`, `value`, `units`, `adder`, `min_dist`, `max_dist`, -`summary`, `year`, `section`, `source`. +`summary`, `ordinance_text`, `explanation`, `year`, `section`, `source`. + +Quantitative and qualitative rows share one file. Select the qualitative +rows with `units == "str"` — qualitative features have no measurable +units, so `units` carries that literal instead. + +The four content columns have distinct jobs: + +| Column | Contents | +|---|---| +| `value` | The extracted answer — a number or category for quantitative features. On qualitative rows the LLM returns null and the `summary` text is copied in, so the column is never blank. | +| `summary` | Prose restatement of the rule. For quantitative features it carries caveats and conditions that `value` and `units` cannot; for qualitative features it *is* the requirement. | +| `ordinance_text` | Exact quotes copied from the source document, trimmed to 5000 characters. | +| `explanation` | The model's reasoning about how it interpreted the requirement. | + +The LLM only ever writes the requirement once: qualitative rows are +asked for a `summary` and a null `value`, and the copy happens when the +output is written. ## Interpreting output status correctly @@ -186,8 +202,7 @@ Check in order: 1. `outputs/*/cleaned_text/*.txt` (text extraction present) 2. `outputs/*/jurisdiction_dbs/*.csv` (per-jurisdiction parsed rows) -3. `outputs/*/quantitative_ordinances.csv` and - `outputs/*/qualitative_ordinances.csv` (final compiled results) +3. `outputs/*/ordinances.csv` (final compiled results) Treat the run as **failed for extraction quality** when either is true: - `Number of jurisdictions with extracted data: 0` diff --git a/compass/common/base.py b/compass/common/base.py index 74c3356ca..c25621da0 100644 --- a/compass/common/base.py +++ b/compass/common/base.py @@ -18,11 +18,33 @@ "and `null` otherwise." ) _SUMMARY_PROMPT = ( - "The value of the 'summary' key should be a short summary of the relevant " - "ordinance, **using direct text excerpts as much as possible.** " + "The value of the 'summary' key should be a short summary of the " + "relevant ordinance, capturing the gist of the requirement along with " + "all of its specifics and details. Do not copy the ordinance wording " + "here; the verbatim excerpt belongs in 'ordinance_text'. " "If you had to make a selection when reporting the ordinance, be sure to " "list out all the other options and their conditions in the summary." ) +_ORDINANCE_TEXT_PROMPT = ( + "The value of the 'ordinance_text' key should be the complete relevant " + "text excerpt containing the ordinance information, copied verbatim from " + "the document. The first sentence must be the one that corresponds to " + "the extracted value and summary, reproduced in full without elision. " + "After that first sentence, gather any other passages in the document " + "that bear on this same requirement, whether they appear before or " + "after it, and join them with an ellipsis ('...') in the order they " + "appear in the document. Use the ellipsis to skip over text that is not " + "relevant. Do not paraphrase, summarize, normalize, or add commentary " + "of your own; every character outside of the ellipses must appear " + "exactly as written in the document." +) +_EXPLANATION_PROMPT = ( + "The value of the 'explanation' key should be a brief rationale " + "explaining why the cited ordinance text matches this feature, " + "referencing the specific evidence in 'ordinance_text' and how it " + "supports the extracted value and units (or, for qualitative features, " + "the inclusion criteria)." +) _YES_NO_PROMPT = ( "Please start your response with either 'Yes' or 'No' and briefly " "explain your answer." @@ -69,6 +91,8 @@ def setup_graph_no_nodes(d_tree_name="Unknown Decision Tree", **kwargs): return nx.DiGraph( SECTION_PROMPT=_SECTION_PROMPT, SUMMARY_PROMPT=_SUMMARY_PROMPT, + ORDINANCE_TEXT_PROMPT=_ORDINANCE_TEXT_PROMPT, + EXPLANATION_PROMPT=_EXPLANATION_PROMPT, YES_NO_PROMPT=_YES_NO_PROMPT, UNITS_IN_SUMMARY_PROMPT=_UNITS_IN_SUMMARY_PROMPT, _d_tree_name=d_tree_name, @@ -490,7 +514,8 @@ def setup_graph_extra_restriction(is_numerical=True, **kwargs): "Please respond based on our entire conversation so far. " "Return your answer as a dictionary in " "JSON format (not markdown). Your JSON file must include " - "exactly four keys. The keys are 'value', 'units', 'summary', " + "exactly six keys. The keys are 'value', 'units', 'summary', " + "'ordinance_text', 'explanation', " "and 'section'. The value of the 'value' key " "should be a numerical value corresponding to the " "{restriction} for {tech}, or `null` if the text " @@ -502,7 +527,9 @@ def setup_graph_extra_restriction(is_numerical=True, **kwargs): "restriction. " "As before, focus only on {restriction} specifically for " "{system_size_reminder}" - "{SUMMARY_PROMPT} {UNITS_IN_SUMMARY_PROMPT} {SECTION_PROMPT}" + "{SUMMARY_PROMPT} {UNITS_IN_SUMMARY_PROMPT} " + "{ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " + "{SECTION_PROMPT}" ), ) @@ -517,8 +544,10 @@ def setup_graph_extra_restriction(is_numerical=True, **kwargs): "Please respond based on our entire conversation so far. " "Return your answer as a dictionary in " "JSON format (not markdown). Your JSON file must include " - "exactly two keys. The keys are 'summary' and 'section'. " - "{SUMMARY_PROMPT} {SECTION_PROMPT}" + "exactly four keys. The keys are 'summary', " + "'ordinance_text', 'explanation', and 'section'. " + "{SUMMARY_PROMPT} {ORDINANCE_TEXT_PROMPT} " + "{EXPLANATION_PROMPT} {SECTION_PROMPT}" ), ) @@ -559,12 +588,14 @@ def setup_graph_extra_restriction(is_numerical=True, **kwargs): prompt=( "Please respond based on our entire conversation so far. " "Return your answer as a dictionary in JSON format (not " - "markdown). Your JSON file must include exactly three keys. " - "The keys are 'value', 'summary', and 'section'. The value of " + "markdown). Your JSON file must include exactly five keys. " + "The keys are 'value', 'summary', 'ordinance_text', " + "'explanation', and 'section'. The value of " "the 'value' key should be the string 'ENR' if the text " "clearly states that the jurisdiction does not regulate " "{restriction} for {tech} or `null` otherwise. " - "{SUMMARY_PROMPT} {SECTION_PROMPT}" + "{SUMMARY_PROMPT} {ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " + "{SECTION_PROMPT}" ), ) @@ -864,10 +895,12 @@ def _add_prohibitions_extraction_nodes(G): # ruff:ignore[invalid-argument-name] "Please respond based on our entire conversation so far. " "Return your answer as a dictionary in " "JSON format (not markdown). Your JSON file must include " - "exactly two keys. The keys are 'summary' and 'section'. " + "exactly four keys. The keys are 'summary', 'ordinance_text', " + "'explanation', and 'section'. " "{SUMMARY_PROMPT} If the prohibition is a moratorium, be " "sure to include that distinction in your summary and " - "provide any relevant expiration dates. {SECTION_PROMPT}" + "provide any relevant expiration dates. " + "{ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} {SECTION_PROMPT}" ), ) return G @@ -953,13 +986,15 @@ def setup_graph_permitted_use_districts(**kwargs): "Please respond based on our entire conversation so far. " "Return your answer as a dictionary in " "JSON format (not markdown). Your JSON file must include " - "exactly three keys. The keys are 'value', 'summary', " + "exactly five keys. The keys are 'value', 'summary', " + "'ordinance_text', 'explanation', " "and 'section'. The value of the 'value' key " "should be a list of all district names (and abbreviations if " "given) where {tech} (or similar) " "are {use_type}, or `null` if the text does not " "mention this use type for {tech} (or similar). Use our " "conversation to fill out this value. {SUMMARY_PROMPT} " + "{ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " "{SECTION_PROMPT}" ), ) diff --git a/compass/extraction/geothermal_electricity/geothermal_schema.json b/compass/extraction/geothermal_electricity/geothermal_schema.json index 62db443e0..3ad7fda88 100644 --- a/compass/extraction/geothermal_electricity/geothermal_schema.json +++ b/compass/extraction/geothermal_electricity/geothermal_schema.json @@ -17,6 +17,7 @@ "units", "section", "summary", + "ordinance_text", "explanation" ], "additionalProperties": false, @@ -84,7 +85,11 @@ }, "summary": { "type": "string", - "description": "A short summary with direct ordinance excerpts or quotes whenever possible. For qualitative features such as permitting, fencing, lighting, seismic monitoring, decommissioning, and prohibitions, this is the primary output field and should contain direct ordinance language. For numeric features, summary must support the same requirement used to extract value and units. Must be a non-null, non-empty string. Do not output absence placeholders such as 'No explicit requirement found'; omit the feature instead when no requirement is present." + "description": "A short summary capturing the gist of the requirement along with all of its specifics and details. Do not copy the ordinance wording here; the verbatim excerpt belongs in ordinance_text. For qualitative features such as permitting, fencing, lighting, seismic monitoring, decommissioning, and prohibitions, this is the primary output field and should state the requirement in full. For numeric features, summary must support the same requirement used to extract value and units. Must be a non-null, non-empty string. Do not output absence placeholders such as 'No explicit requirement found'; omit the feature instead when no requirement is present." + }, + "ordinance_text": { + "type": "string", + "description": "The complete relevant text excerpt containing the ordinance information, copy-pasted verbatim from the source document. The first sentence must be the one that corresponds to the extracted value and summary, reproduced in full without elision. After that first sentence, gather any other passages in the document that bear on this same requirement, whether they appear before or after it, and join them with an ellipsis ('...') in the order they appear in the document. Use the ellipsis to skip over text that is not relevant so the excerpt stays focused while still carrying every passage that supports the extracted value, units, or qualitative requirement. Every character outside of the ellipses must appear exactly as written in the source: no paraphrasing, summarizing, normalization, added context, or commentary. Must be a non-null, non-empty string." }, "explanation": { "type": "string", @@ -241,7 +246,8 @@ "value": 500, "units": "feet", "section": "Section 8.4 - Setbacks", - "summary": "'Geothermal production wells and associated facilities shall be set back at least 500 feet from all property lines.'", + "summary": "Wells and related facilities must sit at least 500 feet from any property line. The setback is measured to the outer edge of the wellhead cellar rather than the well bore itself.", + "ordinance_text": "Geothermal production wells and associated facilities shall be set back at least 500 feet from all property lines. ... For purposes of this section, the setback shall be measured from the outer edge of the wellhead cellar.", "explanation": "The excerpt states an explicit numeric minimum separation from property lines, so it maps directly to 'property lines distance' with value 500 feet." }, { @@ -252,7 +258,8 @@ ], "units": null, "section": "Table 4 - Allowed Uses", - "summary": "'Geothermal power plants may be approved as conditional uses in the Industrial and Agricultural districts subject to county review.'", + "summary": "Geothermal power plants are not permitted outright anywhere. They are allowed in the Industrial and Agricultural districts only after conditional-use approval by the county.", + "ordinance_text": "Geothermal power plants may be approved as conditional uses in the Industrial and Agricultural districts subject to county review.", "explanation": "The ordinance explicitly lists two districts where geothermal power plants are allowed only through conditional-use approval, so this belongs under 'special use districts' with the district names preserved as an array." }, { @@ -263,7 +270,8 @@ ], "units": null, "section": "Section 5.2 - Approval Process", - "summary": "'A conditional use permit and county drilling permit shall be obtained prior to the construction or operation of any geothermal power plant or exploratory well.'", + "summary": "Two separate approvals are needed before work begins: a conditional use permit and a county drilling permit. Both must be in hand prior to construction or operation, including for exploratory wells.", + "ordinance_text": "A conditional use permit and county drilling permit shall be obtained prior to the construction or operation of any geothermal power plant or exploratory well.", "explanation": "The clause explicitly requires two project approvals before geothermal development can start, so it belongs under 'required permits' with both permit names preserved as an array." }, { @@ -271,7 +279,8 @@ "value": "07:00", "units": "HH:MM (24-hour)", "section": "Section 6.7 - Drilling Operations", - "summary": "'Routine geothermal drilling activities may occur only between 7:00 a.m. and 7:00 p.m., Monday through Saturday, and shall not occur on Sundays or legal holidays except in an emergency.'", + "summary": "Routine drilling may not begin before 7:00 a.m. The window runs Monday through Saturday, with no work on Sundays or legal holidays unless an emergency applies.", + "ordinance_text": "Routine geothermal drilling activities may occur only between 7:00 a.m. and 7:00 p.m., Monday through Saturday, and shall not occur on Sundays or legal holidays except in an emergency.", "explanation": "The ordinance gives an explicit drilling window beginning at 7:00 a.m., normalized to 24-hour time as 07:00." }, { @@ -279,7 +288,8 @@ "value": "19:00", "units": "HH:MM (24-hour)", "section": "Section 6.7 - Drilling Operations", - "summary": "'Routine geothermal drilling activities may occur only between 7:00 a.m. and 7:00 p.m., Monday through Saturday, and shall not occur on Sundays or legal holidays except in an emergency.'", + "summary": "Routine drilling must stop by 7:00 p.m. The same Monday through Saturday window applies, and emergency work is the only exception to the Sunday and holiday prohibition.", + "ordinance_text": "Routine geothermal drilling activities may occur only between 7:00 a.m. and 7:00 p.m., Monday through Saturday, and shall not occur on Sundays or legal holidays except in an emergency.", "explanation": "The ordinance gives an explicit drilling window ending at 7:00 p.m., normalized to 24-hour time as 19:00." }, { @@ -287,7 +297,8 @@ "value": null, "units": null, "section": "Section 9.3 - Financial Assurance", - "summary": "'Prior to permit issuance, the operator shall provide financial assurance in a form acceptable to the state oil, gas, and geothermal agency in an amount sufficient to cover plugging, abandonment, reclamation, and decommissioning costs as determined by the agency engineer.'", + "summary": "Financial assurance is required before a permit issues, but the ordinance sets no dollar figure. The amount is determined by the state agency engineer and must cover plugging, abandonment, reclamation, and decommissioning, so no numeric value can be extracted.", + "ordinance_text": "Prior to permit issuance, the operator shall provide financial assurance in a form acceptable to the state oil, gas, and geothermal agency in an amount sufficient to cover plugging, abandonment, reclamation, and decommissioning costs as determined by the agency engineer.", "explanation": "The clause imposes an enforceable financial assurance requirement but leaves the amount to an agency-determined formula, so it fits 'bond requirement' with value and units set to null." } ] @@ -301,6 +312,7 @@ "For any numeric feature, the summary must support the same requirement that produced value and units for that row. Never pair a numeric value from one clause with qualitative-only language from another clause that has no numeric threshold.", "Standardize units in the units field using this schema's canonical vocabulary, while preserving ordinance-specific wording in summary.", "Summary is the primary data carrier for all features in this schema; every row must have a non-null, non-empty string for summary.", + "Every row must include an ordinance_text holding the complete relevant excerpt for the ordinance being extracted, copy-pasted verbatim from the source document. Its first sentence must be the start of the ordinance text for this extraction, reproduced in full; after that first sentence an ellipsis ('...') may be used to drop irrelevant passages. Unlike summary, ordinance_text is never paraphrased or normalized — every character outside the ellipses appears exactly as written in the source.", "Every row must include an explanation that briefly justifies why the cited summary evidence matches the selected feature under this schema's rules.", "Emit only positively matched features. Never emit a row to explain why a feature does not apply.", "The outputs array is a sparse long-form extraction table and does not need to contain every enumerated feature.", diff --git a/compass/extraction/ghp/geothermal_heat_pump_schema.json5 b/compass/extraction/ghp/geothermal_heat_pump_schema.json5 index 99097e28b..e47d20363 100644 --- a/compass/extraction/ghp/geothermal_heat_pump_schema.json5 +++ b/compass/extraction/ghp/geothermal_heat_pump_schema.json5 @@ -17,6 +17,7 @@ "units", "section", "summary", + "ordinance_text", "explanation" ], "additionalProperties": false, @@ -79,7 +80,11 @@ }, "summary": { "type": "string", - "description": "A short summary with direct ordinance excerpts/quotes whenever possible. For qualitative features (definitions, permitting, screening, inspection, decommissioning, prohibitions), this is the primary output field and should contain a direct ordinance excerpt. For numeric features, summary must support the same requirement used to extract value and units. Must be a non-null, non-empty string. Do not output absence placeholders (for example, 'No explicit ... found'); omit the feature instead when no requirement is present." + "description": "A short summary capturing the gist of the requirement along with all of its specifics and details. Do not copy the ordinance wording here; the verbatim excerpt belongs in ordinance_text. For qualitative features (definitions, permitting, screening, inspection, decommissioning, prohibitions), this is the primary output field and should state the requirement in full. For numeric features, summary must support the same requirement used to extract value and units. Must be a non-null, non-empty string. Do not output absence placeholders (for example, 'No explicit ... found'); omit the feature instead when no requirement is present." + }, + "ordinance_text": { + "type": "string", + "description": "The complete relevant text excerpt containing the ordinance information, copy-pasted verbatim from the source document. The first sentence must be the one that corresponds to the extracted value and summary, reproduced in full without elision. After that first sentence, gather any other passages in the document that bear on this same requirement, whether they appear before or after it, and join them with an ellipsis ('...') in the order they appear in the document. Use the ellipsis to skip over text that is not relevant so the excerpt stays focused while still carrying every passage that supports the extracted value, units, or qualitative requirement. Every character outside of the ellipses must appear exactly as written in the source: no paraphrasing, summarizing, normalization, added context, or commentary. Must be a non-null, non-empty string." }, "explanation": { "type": "string", @@ -238,7 +243,8 @@ "value": 100, "units": "feet", "section": "Section 8.3 - Well Siting Standards", - "summary": "'No geothermal borehole shall be located within one hundred (100) feet of any private drinking water well.'", + "summary": "Boreholes must stay at least 100 feet from any private drinking water well. The separation is measured horizontally at the ground surface, so a slanted bore is judged by its surface location.", + "ordinance_text": "No geothermal borehole shall be located within one hundred (100) feet of any private drinking water well. ... All separation distances in this section shall be measured horizontally at the ground surface.", "explanation": "The excerpt states an explicit numeric minimum separation from private drinking water wells, so it maps directly to 'private water' with value 100 feet." }, { @@ -246,7 +252,8 @@ "value": null, "units": null, "section": "Section 6.1 - Contractor Qualifications", - "summary": "'All geothermal wells shall be drilled by a state-licensed well driller in good standing.'", + "summary": "Drilling must be performed by a well driller holding a current state license in good standing.", + "ordinance_text": "All geothermal wells shall be drilled by a state-licensed well driller in good standing.", "explanation": "The clause explicitly ties a licensing credential to the drilling activity itself, which matches 'licensed driller'." }, { @@ -254,7 +261,8 @@ "value": null, "units": null, "section": "Section 12.4 - GEOTHERMAL ENERGY SYSTEM STANDARDS", - "summary": "'Only closed loop ground source heat pump systems utilizing heat transfer fluids as defined in this Zoning Code are permitted. Open loop ground source heat pump systems are not permitted.'", + "summary": "Open loop systems are banned outright. The ordinance permits only closed loop systems using heat transfer fluids as defined in the Zoning Code, so the prohibition is the operative rule for this feature.", + "ordinance_text": "Open loop ground source heat pump systems are not permitted.", "explanation": "The text states an explicit current ban on open-loop systems, so it matches 'prohibitions ol'." } ] @@ -262,12 +270,13 @@ ], "$instructions": { "general": [ - "Use direct text excerpts and quotes in summary whenever possible.", + "Use direct text excerpts and quotes in ordinance_text; summary should paraphrase rather than quote.", "Each feature may appear **at most once** in outputs; do not emit multiple rows for the same feature. If multiple ordinance lines map to one feature, build a temporary map keyed by feature, aggregate all evidence clauses under that feature key, consolidate into one row, and keep the controlling most restrictive value in value while listing alternatives in summary.", - "For any numeric feature, the summary must support the same requirement that produced value and units for that row. If both an explicit numeric threshold and related qualitative language are present, emit one consolidated row using the numeric threshold for value/units and include the qualitative language only as supplemental context in summary. Never pair a numeric value from one clause with qualitative-only language from another clause that has no numeric threshold.", + "For any numeric feature, the ordinance_text must support the same requirement that produced value and units for that row. If both an explicit numeric threshold and related qualitative language are present, emit one consolidated row using the numeric threshold for value/units and include the qualitative language only as supplemental context in summary. Never pair a numeric value from one clause with qualitative-only language from another clause that has no numeric threshold.", "Feature disambiguation for wastewater vs line infrastructure: sewage/wastewater disposal structures (for example septic tanks, cesspools, leach fields, seepage pits, sludge/septage sites) map only to 'wastewater'; explicit sewer or sewage line wording maps only to 'sewer line'; explicit water service line wording maps only to 'water line'. If one clause explicitly names both sewer/sewage line and water line/service piping with a shared numeric setback, emit both line features with that same value and units. Phrases such as 'water and sewer lines' or 'sewage and water line' count as explicitly naming both line features when the shared noun 'line' or 'lines' applies to both utilities.", "Numeric features in this schema are all setback features (driveways, property lines, yards, private water, public water, building foundation, wastewater, water line, sewer line, animal enclosures, roads, row, above ground fuel, below ground fuel, subsurface drains, wetlands, pools, hmat) plus noise, minimum well depth, and maximum well depth.", - "Summary is the primary data carrier for all features in this schema; every row must have a non-null, non-empty string for summary. Use as many direct quotes in the summary as possible, and include all relevant ordinance clauses that support the feature. For numeric features, summary must support the same requirement used to extract value and units. For qualitative features, summary must include direct ordinance excerpts that explicitly state the requirement or definition for that feature.", + "ordinance_text is the primary evidence carrier for all features in this schema; every row must have a non-null, non-empty string for ordinance_text. Summary is a prose restatement used to record caveats and conditions for quantitative features, and is left null for qualitative features, whose value column already states the requirement.", + "Every row must include an ordinance_text holding the complete relevant excerpt for the ordinance being extracted, copy-pasted verbatim from the source document. Its first sentence must be the start of the ordinance text for this extraction, reproduced in full; after that first sentence an ellipsis ('...') may be used to drop irrelevant passages. Unlike summary, ordinance_text is never paraphrased or normalized — every character outside the ellipses appears exactly as written in the source.", "Every row must include an explanation that briefly justifies why the cited summary evidence matches the selected feature under this schema's rules.", "Emit only positively matched features. Never emit a row to explain why a feature does not apply.", "The outputs array is a sparse long-form extraction table and does not need to contain every enumerated feature.", diff --git a/compass/extraction/rmp/rmp_schema.json b/compass/extraction/rmp/rmp_schema.json index 208fca9fe..7eb2e6e10 100644 --- a/compass/extraction/rmp/rmp_schema.json +++ b/compass/extraction/rmp/rmp_schema.json @@ -10,7 +10,7 @@ "type": "array", "items": { "type": "object", - "required": ["feature", "location", "restriction_type", "restriction_level", "geothermal_applicability", "rights_status", "binding_status", "value", "units", "section", "map_reference", "summary", "source"], + "required": ["feature", "location", "restriction_type", "restriction_level", "geothermal_applicability", "rights_status", "binding_status", "value", "units", "section", "map_reference", "summary", "ordinance_text", "explanation", "source"], "additionalProperties": false, "properties": { "feature": { @@ -82,7 +82,15 @@ }, "summary": { "type": ["string", "null"], - "description": "A structured text field with two labeled sections separated by a newline. Format exactly as: 'Summary: [summary text]\\nJustification: [justification text]'. The Summary section (2–4 sentences) covers: (1) the area name and acreage if stated, (2) the exact language used in the document describing what is restricted (quoted where possible), (3) the stated reason or resource being protected, and (4) any conditions, exceptions, or relevant context. Use direct text excerpts and quotes from the document where possible. The Justification section (1–2 sentences) explains why this restriction was included — specifically, how it relates to or could affect geothermal leasing or development (e.g., 'Included because mineral leasing closures under the Mineral Leasing Act encompass geothermal leasing.', 'Included because this NSO stipulation applies to all surface-disturbing activities associated with mineral leasing, which includes geothermal drilling.'). Null only if no relevant information is found." + "description": "A structured text field with two labeled sections separated by a newline. Format exactly as: 'Summary: [summary text]\\nJustification: [justification text]'. The Summary section (2\u20134 sentences) covers: (1) the area name and acreage if stated, (2) the exact language used in the document describing what is restricted (quoted where possible), (3) the stated reason or resource being protected, and (4) any conditions, exceptions, or relevant context. Use direct text excerpts and quotes from the document where possible. The Justification section (1\u20132 sentences) explains why this restriction was included \u2014 specifically, how it relates to or could affect geothermal leasing or development (e.g., 'Included because mineral leasing closures under the Mineral Leasing Act encompass geothermal leasing.', 'Included because this NSO stipulation applies to all surface-disturbing activities associated with mineral leasing, which includes geothermal drilling.'). Null only if no relevant information is found." + }, + "ordinance_text": { + "type": ["string", "null"], + "description": "The complete relevant text excerpt from the document describing what is restricted, copy-pasted verbatim. The first sentence must be the one that corresponds to the extracted value and summary, reproduced in full. After that first sentence, gather any other passages in the document that bear on this same restriction, whether they appear before or after it, and join them with an ellipsis ('...') in the order they appear. Include the area name and acreage wording, the language describing what is restricted, and any stated conditions or exceptions, so long as all of it appears verbatim in the document. Every character outside the ellipses must match the source exactly: no paraphrasing, normalization, or added commentary. Null only if no relevant information is found." + }, + "explanation": { + "type": ["string", "null"], + "description": "A 1-2 sentence justification explaining why this restriction was included - specifically, how it relates to or could affect geothermal leasing or development (e.g., 'Included because mineral leasing closures under the Mineral Leasing Act encompass geothermal leasing.', 'Included because this NSO stipulation applies to all surface-disturbing activities associated with mineral leasing, which includes geothermal drilling.'). Also note the stated reason or resource being protected where the document gives one. Null only if no relevant information is found." }, "source": { "type": ["number", "null"], @@ -140,6 +148,8 @@ "units": null, "section": "Minerals and Energy: Areas Closed to Oil, Gas and Geothermal Leasing", "summary": "Walker Lake is closed to oil, gas, and geothermal leasing as part of 'Key Scenic, Wildlife, Recreation, and Historic Areas' totaling 45,392 acres. Document language: 'Areas Closed to Oil, Gas and Geothermal Leasing.' Individual acreage not specified; group total is 45,392 acres.", + "ordinance_text": "Areas Closed to Oil, Gas and Geothermal Leasing. ... Key Scenic, Wildlife, Recreation, and Historic Areas ... 45,392 acres.", + "explanation": "Walker Lake falls under a closure to oil, gas, and geothermal leasing, so it directly forecloses geothermal leasing in this area. Individual acreage is not specified; the 45,392 acres is the group total.", "source": 0 }, { @@ -153,6 +163,8 @@ "units": "acres", "section": "Minerals and Energy: Areas Closed to Mineral Entry (22,672 Acres)", "summary": "Grimes Point Archaeological Area (400 acres) is closed to mineral entry under the General Mining Law. Stated reason: cultural resource protection. Note: a separate geothermal-specific closure also exists for this area (640 acres) under section 9.", + "ordinance_text": "Areas Closed to Mineral Entry (22,672 Acres) ... Grimes Point Archaeological Area (400 acres).", + "explanation": "Included because closure to mineral entry under the General Mining Law restricts surface use for cultural resource protection. Note that a separate geothermal-specific closure also exists for this area (640 acres) under section 9.", "source": 0 }, { @@ -164,7 +176,9 @@ "value": 1200, "units": "acres", "section": "Table 2-1: Mineral Stipulations", - "summary": "Jack's Valley (1,200 acres) is closed to all mineral leasing including geothermal. Reason stated: 'Key Scenic, Wildlife, Recreation, and Historic Areas'. Closure applies to oil, gas, and geothermal leasing." + "summary": "Jack's Valley (1,200 acres) is closed to all mineral leasing including geothermal. Reason stated: 'Key Scenic, Wildlife, Recreation, and Historic Areas'. Closure applies to oil, gas, and geothermal leasing.", + "ordinance_text": "Jack's Valley (1,200 acres) ... Key Scenic, Wildlife, Recreation, and Historic Areas.", + "explanation": "Included because the closure applies to all mineral leasing, which encompasses oil, gas, and geothermal leasing." }, { "feature": "geothermal_closure", @@ -175,7 +189,9 @@ "value": null, "units": null, "section": "Chapter 3: Energy and Minerals, Special Designations", - "summary": "Grimes Point Archaeological Area is closed specifically to geothermal leasing. Stated reason: 'cultural resource protection'. Acreage not specified in document." + "summary": "Grimes Point Archaeological Area is closed specifically to geothermal leasing. Stated reason: 'cultural resource protection'. Acreage not specified in document.", + "ordinance_text": "Grimes Point Archaeological Area ... closed to geothermal leasing ... cultural resource protection.", + "explanation": "Included because this is an explicit geothermal leasing closure. Acreage is not specified in the document." }, { "feature": "no_surface_occupancy", @@ -186,7 +202,9 @@ "value": 500, "units": "feet", "section": "Appendix B: Lease Stipulations, Stipulation NSO-1", - "summary": "No Surface Occupancy stipulation applies within 500 feet of any perennial or intermittent stream, river, lake, or reservoir. Applies to all geothermal and mineral lease activities. No acreage specified. Details: 'No surface-disturbing activities associated with mineral leasing operations will be permitted within 500 feet of any water body.'" + "summary": "No Surface Occupancy stipulation applies within 500 feet of any perennial or intermittent stream, river, lake, or reservoir. Applies to all geothermal and mineral lease activities. No acreage specified. Details: 'No surface-disturbing activities associated with mineral leasing operations will be permitted within 500 feet of any water body.'", + "ordinance_text": "No surface-disturbing activities associated with mineral leasing operations will be permitted within 500 feet of any water body.", + "explanation": "Included because the NSO stipulation applies to all surface-disturbing activities associated with mineral leasing, which includes geothermal drilling. No acreage is specified." }, { "feature": "no_surface_occupancy", @@ -197,7 +215,9 @@ "value": null, "units": null, "section": "Appendix B: Lease Stipulations, Stipulation NSO-4", - "summary": "No Surface Occupancy applies within all designated Riparian Management Areas. No numeric setback distance specified — restriction applies to the full extent of the riparian management zone. Approximately 3,400 acres affected. Applies to all surface-disturbing activities associated with mineral leasing." + "summary": "No Surface Occupancy applies within all designated Riparian Management Areas. No numeric setback distance specified \u2014 restriction applies to the full extent of the riparian management zone. Approximately 3,400 acres affected. Applies to all surface-disturbing activities associated with mineral leasing.", + "ordinance_text": "No Surface Occupancy ... within all designated Riparian Management Areas ... approximately 3,400 acres.", + "explanation": "Included because the stipulation applies to all surface-disturbing activities associated with mineral leasing. No numeric setback distance is given; the restriction covers the full extent of the riparian management zone." }, { "feature": "seasonal_restriction", @@ -209,7 +229,9 @@ "value": "March 1 - July 30", "units": "dates", "section": "Appendix B: Lease Stipulations, Stipulation TL-3", - "summary": "Seasonal timing limitation applies in Sage Grouse breeding habitat. Species protected: Sage Grouse. Restriction period: March 1 through July 30. Activity restricted: 'all surface-disturbing activities'. Approximately 45,000 acres affected. Applies to geothermal and all mineral leasing operations within designated Sage Grouse habitat areas." + "summary": "Seasonal timing limitation applies in Sage Grouse breeding habitat. Species protected: Sage Grouse. Restriction period: March 1 through July 30. Activity restricted: 'all surface-disturbing activities'. Approximately 45,000 acres affected. Applies to geothermal and all mineral leasing operations within designated Sage Grouse habitat areas.", + "ordinance_text": "all surface-disturbing activities ... March 1 through July 30 ... Sage Grouse breeding habitat ... approximately 45,000 acres.", + "explanation": "Included because the seasonal timing limitation applies to geothermal and all other mineral leasing operations within designated Sage Grouse habitat. Species protected: Sage Grouse." }, { "feature": "seasonal_restriction", @@ -221,7 +243,9 @@ "value": "spring", "units": "dates", "section": "Areas Where Some Restrictions Apply to Geothermal Leasing: Seasonal Restrictions, Spring Restrictions", - "summary": "Spring seasonal restriction applies north of Cold Springs (referenced in Fort Churchill/Clan Alpine Geothermal EAR 1975). Restriction period: spring season. Activity restricted: surface-disturbing activities associated with geothermal leasing operations. No acreage specified for this subarea." + "summary": "Spring seasonal restriction applies north of Cold Springs (referenced in Fort Churchill/Clan Alpine Geothermal EAR 1975). Restriction period: spring season. Activity restricted: surface-disturbing activities associated with geothermal leasing operations. No acreage specified for this subarea.", + "ordinance_text": "north of Cold Springs ... spring season ... surface-disturbing activities associated with geothermal leasing operations.", + "explanation": "Included because the restriction explicitly names geothermal leasing operations. Referenced in the Fort Churchill/Clan Alpine Geothermal EAR 1975. No acreage is specified for this subarea." }, { "feature": "wildlife_restriction", @@ -232,7 +256,9 @@ "value": 0.25, "units": "miles", "section": "Chapter 3: Wildlife, Special Status Species", - "summary": "No surface disturbance allowed within 0.25 miles of active Prairie Falcon nest sites. Species: Prairie Falcon (special status). Restriction type: buffer zone / no surface disturbance. Applies to all energy development and surface-disturbing activities associated with mineral leasing. Details extracted as written: 'Avoid all surface-disturbing activities within one-quarter mile of active Prairie Falcon nesting sites.'" + "summary": "No surface disturbance allowed within 0.25 miles of active Prairie Falcon nest sites. Species: Prairie Falcon (special status). Restriction type: buffer zone / no surface disturbance. Applies to all energy development and surface-disturbing activities associated with mineral leasing. Details extracted as written: 'Avoid all surface-disturbing activities within one-quarter mile of active Prairie Falcon nesting sites.'", + "ordinance_text": "Avoid all surface-disturbing activities within one-quarter mile of active Prairie Falcon nesting sites.", + "explanation": "Included because the buffer applies to all energy development and surface-disturbing activities associated with mineral leasing. Species: Prairie Falcon (special status)." }, { "feature": "water_restriction", @@ -243,7 +269,9 @@ "value": 300, "units": "feet", "section": "Appendix B: Lease Stipulations, Stipulation NSO-2", - "summary": "No Surface Occupancy setback of 300 feet from all springs, seeps, and wetlands. Restriction type: no surface occupancy. Applies to all geothermal and mineral leasing surface-disturbing activities. Details: 'No surface occupancy or surface-disturbing activities will be permitted within 300 feet of springs, seeps, or wetland areas.'" + "summary": "No Surface Occupancy setback of 300 feet from all springs, seeps, and wetlands. Restriction type: no surface occupancy. Applies to all geothermal and mineral leasing surface-disturbing activities. Details: 'No surface occupancy or surface-disturbing activities will be permitted within 300 feet of springs, seeps, or wetland areas.'", + "ordinance_text": "No surface occupancy or surface-disturbing activities will be permitted within 300 feet of springs, seeps, or wetland areas.", + "explanation": "Included because the NSO setback applies to all geothermal and mineral leasing surface-disturbing activities." }, { "feature": "other_restriction", @@ -254,7 +282,9 @@ "value": "cultural resources", "units": null, "section": "Chapter 3: Cultural Resources", - "summary": "Applies planning-area wide to all ground-disturbing activities associated with geothermal or mineral leasing. Full restriction: 'Prior to any ground-disturbing activities, a Class III cultural resource inventory must be completed and approved by the authorized BLM officer. Any identified cultural resources must be avoided or a mitigation plan approved before surface disturbance may proceed.'" + "summary": "Applies planning-area wide to all ground-disturbing activities associated with geothermal or mineral leasing. Full restriction: 'Prior to any ground-disturbing activities, a Class III cultural resource inventory must be completed and approved by the authorized BLM officer. Any identified cultural resources must be avoided or a mitigation plan approved before surface disturbance may proceed.'", + "ordinance_text": "Prior to any ground-disturbing activities, a Class III cultural resource inventory must be completed and approved by the authorized BLM officer. Any identified cultural resources must be avoided or a mitigation plan approved before surface disturbance may proceed.", + "explanation": "Included because the requirement applies planning-area wide to all ground-disturbing activities associated with geothermal or mineral leasing." }, { "feature": "resource_removals", @@ -265,7 +295,9 @@ "value": 2956, "units": "acres", "section": "Plan Amendment Decision: Southern Washoe County Urban Interface", - "summary": "'Removed all 2,956 acres available for geothermal leasing in the Warm Springs area due to the lack of a sufficient resource for development and no existing leases.' Action: removed from geothermal leasing availability. Reason: insufficient geothermal resource and no existing leases. Enacted by Southern Washoe County Urban Interface Plan Amendment (2001)." + "summary": "'Removed all 2,956 acres available for geothermal leasing in the Warm Springs area due to the lack of a sufficient resource for development and no existing leases.' Action: removed from geothermal leasing availability. Reason: insufficient geothermal resource and no existing leases. Enacted by Southern Washoe County Urban Interface Plan Amendment (2001).", + "ordinance_text": "Removed all 2,956 acres available for geothermal leasing in the Warm Springs area due to the lack of a sufficient resource for development and no existing leases.", + "explanation": "Included because the amendment explicitly removes acreage from geothermal leasing availability. Reason given: insufficient geothermal resource and no existing leases. Enacted by the Southern Washoe County Urban Interface Plan Amendment (2001)." }, { "feature": "geothermal_closure_with_stipulations", @@ -276,7 +308,9 @@ "value": 196128, "units": "acres", "section": "Chapter 2: Wilderness Study Areas, Leasable Minerals SOP", - "summary": "'Clan Alpine WSA 196,128 acres.' Managed under the Wilderness Interim Management Policy (IMP): 'restrict mining and energy development activities to those that are allowed under valid existing rights and do not impair wilderness quality.' No new geothermal or mineral leasing permitted. Valid existing rights honored. If designated as wilderness by Congress, will be fully closed to mineral entry." + "summary": "'Clan Alpine WSA 196,128 acres.' Managed under the Wilderness Interim Management Policy (IMP): 'restrict mining and energy development activities to those that are allowed under valid existing rights and do not impair wilderness quality.' No new geothermal or mineral leasing permitted. Valid existing rights honored. If designated as wilderness by Congress, will be fully closed to mineral entry.", + "ordinance_text": "Clan Alpine WSA 196,128 acres. ... restrict mining and energy development activities to those that are allowed under valid existing rights and do not impair wilderness quality.", + "explanation": "Included because management under the Wilderness Interim Management Policy bars new geothermal or mineral leasing while honoring valid existing rights. If designated as wilderness by Congress, the area would be fully closed to mineral entry." }, { "feature": "geothermal_closure_with_stipulations", @@ -287,7 +321,9 @@ "value": 8000, "units": "acres", "section": "Chapter 2: Mineral Entry and Energy Development", - "summary": "'Approximately 8,000 acres in Sun Valley, Washoe Valley, Steamboat and Peavine Mountain' classified under the Classification and Multiple Use Act. These are discretionary closures — lands segregated from mineral entry under specific legal authority. New mineral leasing and energy development not permitted under current classification." + "summary": "'Approximately 8,000 acres in Sun Valley, Washoe Valley, Steamboat and Peavine Mountain' classified under the Classification and Multiple Use Act. These are discretionary closures \u2014 lands segregated from mineral entry under specific legal authority. New mineral leasing and energy development not permitted under current classification.", + "ordinance_text": "Approximately 8,000 acres in Sun Valley, Washoe Valley, Steamboat and Peavine Mountain.", + "explanation": "Included because these lands are segregated from mineral entry under the Classification and Multiple Use Act, so new mineral leasing and energy development are not permitted under the current classification. These are discretionary closures." }, { "feature": "controlled_surface_use", @@ -298,7 +334,9 @@ "value": null, "units": null, "section": "Appendix B: Lease Stipulations, Stipulation CSU-2", - "summary": "Controlled Surface Use stipulation applies within designated Sage Grouse habitat in the East Walker River Area. Leasing and surface operations are permitted but subject to required mitigation: operator must prepare and implement a site-specific mitigation plan minimizing disturbance to sage grouse habitat, including requirements for revegetation with native species, limitations on infrastructure footprint, and reporting to BLM. Stipulation code: CSU-2." + "summary": "Controlled Surface Use stipulation applies within designated Sage Grouse habitat in the East Walker River Area. Leasing and surface operations are permitted but subject to required mitigation: operator must prepare and implement a site-specific mitigation plan minimizing disturbance to sage grouse habitat, including requirements for revegetation with native species, limitations on infrastructure footprint, and reporting to BLM. Stipulation code: CSU-2.", + "ordinance_text": "Controlled Surface Use ... within designated Sage Grouse habitat in the East Walker River Area ... CSU-2.", + "explanation": "Included because leasing and surface operations remain permitted but are subject to required mitigation: a site-specific mitigation plan minimizing disturbance to sage grouse habitat, revegetation with native species, limits on infrastructure footprint, and reporting to BLM." }, { "feature": "resource_additions", @@ -309,7 +347,9 @@ "value": 1933, "units": "acres", "section": "Plan Amendment Decision: Southern Washoe County Urban Interface, Map 3", - "summary": "'Geothermal leasing on 1,933 acres in and adjacent to the Steamboat Known Geothermal Resource Area (KGRA)' retained as open to leasing within an otherwise closed planning area. Action: 1,933 acres explicitly kept open for geothermal leasing. Reason: active KGRA with existing geothermal development potential. Enacted by Southern Washoe County Urban Interface Plan Amendment (2001)." + "summary": "'Geothermal leasing on 1,933 acres in and adjacent to the Steamboat Known Geothermal Resource Area (KGRA)' retained as open to leasing within an otherwise closed planning area. Action: 1,933 acres explicitly kept open for geothermal leasing. Reason: active KGRA with existing geothermal development potential. Enacted by Southern Washoe County Urban Interface Plan Amendment (2001).", + "ordinance_text": "Geothermal leasing on 1,933 acres in and adjacent to the Steamboat Known Geothermal Resource Area (KGRA).", + "explanation": "Included because this acreage is explicitly retained as open to geothermal leasing within an otherwise closed planning area. Reason: active KGRA with existing geothermal development potential. Enacted by the Southern Washoe County Urban Interface Plan Amendment (2001)." } ], diff --git a/compass/extraction/small_wind/graphs.py b/compass/extraction/small_wind/graphs.py index 9074fec58..541cb1f62 100644 --- a/compass/extraction/small_wind/graphs.py +++ b/compass/extraction/small_wind/graphs.py @@ -215,7 +215,8 @@ def setup_multiplier(**kwargs): "Please respond based on our entire conversation so far. " "Return your answer in JSON " "format (not markdown). Your JSON file must include exactly " - "four keys. The keys are 'value', 'units', 'summary', and " + "six keys. The keys are 'value', 'units', 'summary', " + "'ordinance_text', 'explanation', and " "'section'. The value of the 'value' key should be a " "**numerical** value corresponding to the setback distance value " "from {feature} or `null` if there was no such value. " @@ -224,7 +225,8 @@ def setup_multiplier(**kwargs): "{feature} or `null` if there was no such value. " "As before, focus only on setbacks that would apply for " "{system_size_reminder}" - "{SUMMARY_PROMPT} {SECTION_PROMPT}" + "{SUMMARY_PROMPT} {ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " + "{SECTION_PROMPT}" ), ) G.add_edge("init", "m_single", condition=llm_response_starts_with_yes) @@ -311,9 +313,10 @@ def setup_multiplier(**kwargs): prompt=( "Please respond based on our entire conversation so far. " "Return your answer as a single dictionary in JSON " - "format (not markdown). Your JSON file must include exactly five " - "keys. The keys are 'mult_value', 'mult_type', 'adder', " - "'summary', and 'section'. The value of the " + "format (not markdown). Your JSON file must include exactly " + "seven keys. The keys are 'mult_value', 'mult_type', 'adder', " + "'summary', 'ordinance_text', 'explanation', and " + "'section'. The value of the " "'mult_value' key should be a **numerical** value corresponding " "to the multiplier value we determined earlier. The value of the " "'mult_type' key should be a string corresponding to the " @@ -322,7 +325,8 @@ def setup_multiplier(**kwargs): "**numerical** value corresponding to the static value to be " "added to the total setback distance after multiplication, as we " "determined earlier, or `null` if there is no such value. " - "{SUMMARY_PROMPT} {SECTION_PROMPT}" + "{SUMMARY_PROMPT} {ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " + "{SECTION_PROMPT}" ), ) G.add_node( @@ -330,15 +334,17 @@ def setup_multiplier(**kwargs): prompt=( "Please respond based on our entire conversation so far. " "Return your answer as a single dictionary in JSON " - "format (not markdown). Your JSON file must include exactly four " + "format (not markdown). Your JSON file must include exactly six " "keys. The keys are 'mult_value', 'mult_type', " - "'summary', and 'section'. The value of the " + "'summary', 'ordinance_text', 'explanation', and " + "'section'. The value of the " "'mult_value' key should be a **numerical** value corresponding " "to the multiplier value we determined earlier. The value of the " "'mult_type' key should be a string corresponding to the " "dimension that the multiplier should be applied to, as we " "determined earlier. " - "{SUMMARY_PROMPT} {SECTION_PROMPT}" + "{SUMMARY_PROMPT} {ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " + "{SECTION_PROMPT}" ), ) diff --git a/compass/extraction/small_wind/plugin.py b/compass/extraction/small_wind/plugin.py index f2614a9dc..a5cf69c32 100644 --- a/compass/extraction/small_wind/plugin.py +++ b/compass/extraction/small_wind/plugin.py @@ -109,19 +109,16 @@ class COMPASSSmallWindExtractor(OrdinanceExtractionPlugin): OutputColumn("FIPS"), OutputColumn("feature"), OutputColumn("value"), - OutputColumn("units", include_in_qual_output=False), - OutputColumn("adder", include_in_qual_output=False), - OutputColumn("min_dist", include_in_qual_output=False), - OutputColumn("max_dist", include_in_qual_output=False), + OutputColumn("units"), + OutputColumn("adder"), + OutputColumn("min_dist"), + OutputColumn("max_dist"), OutputColumn("summary"), + OutputColumn("ordinance_text"), + OutputColumn("explanation"), OutputColumn("year"), OutputColumn("section"), OutputColumn("source"), - OutputColumn( - "quantitative", - include_in_quant_output=False, - include_in_qual_output=False, - ), ] """list: List of output columns for the extracted data""" diff --git a/compass/extraction/solar/graphs.py b/compass/extraction/solar/graphs.py index a4d10309b..7fffdcf0f 100644 --- a/compass/extraction/solar/graphs.py +++ b/compass/extraction/solar/graphs.py @@ -215,7 +215,8 @@ def setup_multiplier(**kwargs): "Please respond based on our entire conversation so far. " "Return your answer in JSON " "format (not markdown). Your JSON file must include exactly " - "four keys. The keys are 'value', 'units', 'summary', and " + "six keys. The keys are 'value', 'units', 'summary', " + "'ordinance_text', 'explanation', and " "'section'. The value of the 'value' key should be a " "**numerical** value corresponding to the setback distance value " "from {feature} or `null` if there was no such value. The value " @@ -224,7 +225,8 @@ def setup_multiplier(**kwargs): "or `null` if there was no such value. " "As before, focus only on setbacks that would apply for" "{system_size_reminder}" - "{SUMMARY_PROMPT} {SECTION_PROMPT}" + "{SUMMARY_PROMPT} {ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " + "{SECTION_PROMPT}" ), ) G.add_edge("init", "m_single", condition=llm_response_starts_with_yes) @@ -293,15 +295,17 @@ def setup_multiplier(**kwargs): prompt=( "Please respond based on our entire conversation so far. " "Return your answer as a single dictionary in JSON " - "format (not markdown). Your JSON file must include exactly four " - "keys. The keys are 'mult_value', 'adder', 'summary', and " + "format (not markdown). Your JSON file must include exactly six " + "keys. The keys are 'mult_value', 'adder', 'summary', " + "'ordinance_text', 'explanation', and " "'section'. The value of the 'mult_value' key should be a " "**numerical** value corresponding to the multiplier value we " "determined earlier. The value of the 'adder' key should be a " "**numerical** value corresponding to the static value to be " "added to the total setback distance after multiplication, as we " "determined earlier, or `null` if there is no such value. " - "{SUMMARY_PROMPT} {SECTION_PROMPT}" + "{SUMMARY_PROMPT} {ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " + "{SECTION_PROMPT}" ), ) G.add_node( @@ -309,11 +313,13 @@ def setup_multiplier(**kwargs): prompt=( "Please respond based on our entire conversation so far. " "Return your answer as a single dictionary in JSON " - "format (not markdown). Your JSON file must include exactly three " - "keys. The keys are 'mult_value', 'summary', and 'section'. The " + "format (not markdown). Your JSON file must include exactly five " + "keys. The keys are 'mult_value', 'summary', 'ordinance_text', " + "'explanation', and 'section'. The " "value of the 'mult_value' key should be a **numerical** value " "corresponding to the multiplier value we determined earlier. " - "{SUMMARY_PROMPT} {SECTION_PROMPT}" + "{SUMMARY_PROMPT} {ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " + "{SECTION_PROMPT}" ), ) diff --git a/compass/extraction/solar/plugin.py b/compass/extraction/solar/plugin.py index 81796ee04..79cb8704e 100644 --- a/compass/extraction/solar/plugin.py +++ b/compass/extraction/solar/plugin.py @@ -110,19 +110,16 @@ class COMPASSSolarExtractor(OrdinanceExtractionPlugin): OutputColumn("FIPS"), OutputColumn("feature"), OutputColumn("value"), - OutputColumn("units", include_in_qual_output=False), - OutputColumn("adder", include_in_qual_output=False), - OutputColumn("min_dist", include_in_qual_output=False), - OutputColumn("max_dist", include_in_qual_output=False), + OutputColumn("units"), + OutputColumn("adder"), + OutputColumn("min_dist"), + OutputColumn("max_dist"), OutputColumn("summary"), + OutputColumn("ordinance_text"), + OutputColumn("explanation"), OutputColumn("year"), OutputColumn("section"), OutputColumn("source"), - OutputColumn( - "quantitative", - include_in_quant_output=False, - include_in_qual_output=False, - ), ] """list: List of output columns for the extracted data""" diff --git a/compass/extraction/wind/graphs.py b/compass/extraction/wind/graphs.py index 0ba188069..4d09f93f5 100644 --- a/compass/extraction/wind/graphs.py +++ b/compass/extraction/wind/graphs.py @@ -209,7 +209,8 @@ def setup_multiplier(**kwargs): "Please respond based on our entire conversation so far. " "Return your answer in JSON " "format (not markdown). Your JSON file must include exactly " - "four keys. The keys are 'value', 'units', 'summary', and " + "six keys. The keys are 'value', 'units', 'summary', " + "'ordinance_text', 'explanation', and " "'section'. The value of the 'value' key should be a " "**numerical** value corresponding to the setback distance value " "from {feature} or `null` if there was no such value. " @@ -218,7 +219,8 @@ def setup_multiplier(**kwargs): "{feature} or `null` if there was no such value. " "As before, focus only on setbacks that would apply for " "{system_size_reminder}" - "{SUMMARY_PROMPT} {SECTION_PROMPT}" + "{SUMMARY_PROMPT} {ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " + "{SECTION_PROMPT}" ), ) G.add_edge("init", "m_single", condition=llm_response_starts_with_yes) @@ -305,9 +307,10 @@ def setup_multiplier(**kwargs): prompt=( "Please respond based on our entire conversation so far. " "Return your answer as a single dictionary in JSON " - "format (not markdown). Your JSON file must include exactly five " - "keys. The keys are 'mult_value', 'mult_type', 'adder', " - "'summary', and 'section'. The value of the " + "format (not markdown). Your JSON file must include exactly " + "seven keys. The keys are 'mult_value', 'mult_type', 'adder', " + "'summary', 'ordinance_text', 'explanation', and " + "'section'. The value of the " "'mult_value' key should be a **numerical** value corresponding " "to the multiplier value we determined earlier. The value of the " "'mult_type' key should be a string corresponding to the " @@ -316,7 +319,8 @@ def setup_multiplier(**kwargs): "**numerical** value corresponding to the static value to be " "added to the total setback distance after multiplication, as we " "determined earlier, or `null` if there is no such value. " - "{SUMMARY_PROMPT} {SECTION_PROMPT}" + "{SUMMARY_PROMPT} {ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " + "{SECTION_PROMPT}" ), ) G.add_node( @@ -324,15 +328,17 @@ def setup_multiplier(**kwargs): prompt=( "Please respond based on our entire conversation so far. " "Return your answer as a single dictionary in JSON " - "format (not markdown). Your JSON file must include exactly four " + "format (not markdown). Your JSON file must include exactly six " "keys. The keys are 'mult_value', 'mult_type', " - "'summary', and 'section'. The value of the " + "'summary', 'ordinance_text', 'explanation', and " + "'section'. The value of the " "'mult_value' key should be a **numerical** value corresponding " "to the multiplier value we determined earlier. The value of the " "'mult_type' key should be a string corresponding to the " "dimension that the multiplier should be applied to, as we " "determined earlier. " - "{SUMMARY_PROMPT} {SECTION_PROMPT}" + "{SUMMARY_PROMPT} {ORDINANCE_TEXT_PROMPT} {EXPLANATION_PROMPT} " + "{SECTION_PROMPT}" ), ) diff --git a/compass/extraction/wind/plugin.py b/compass/extraction/wind/plugin.py index 139b56711..d1711b227 100644 --- a/compass/extraction/wind/plugin.py +++ b/compass/extraction/wind/plugin.py @@ -108,19 +108,16 @@ class COMPASSWindExtractor(OrdinanceExtractionPlugin): OutputColumn("FIPS"), OutputColumn("feature"), OutputColumn("value"), - OutputColumn("units", include_in_qual_output=False), - OutputColumn("adder", include_in_qual_output=False), - OutputColumn("min_dist", include_in_qual_output=False), - OutputColumn("max_dist", include_in_qual_output=False), + OutputColumn("units"), + OutputColumn("adder"), + OutputColumn("min_dist"), + OutputColumn("max_dist"), OutputColumn("summary"), + OutputColumn("ordinance_text"), + OutputColumn("explanation"), OutputColumn("year"), OutputColumn("section"), OutputColumn("source"), - OutputColumn( - "quantitative", - include_in_quant_output=False, - include_in_qual_output=False, - ), ] """list: List of output columns for the extracted data""" diff --git a/compass/plugin/one_shot/base.py b/compass/plugin/one_shot/base.py index 4c7c8d011..62b82fead 100644 --- a/compass/plugin/one_shot/base.py +++ b/compass/plugin/one_shot/base.py @@ -580,14 +580,7 @@ def _out_cols_from_config(config): msg = f"Error parsing output columns from schema: {e}" raise COMPASSPluginConfigurationError(msg) from e - cols.extend( - OutputColumn( - name, - include_in_qual_output=name not in {"value", "units"}, - ) - for name in schema_props - if name != "explanation" - ) + cols.extend(OutputColumn(name) for name in schema_props) source_col_ind = next( (ind for ind, col in enumerate(cols) if col.name == "source"), None @@ -597,13 +590,6 @@ def _out_cols_from_config(config): else: cols.insert(source_col_ind, OutputColumn("year")) - cols.append( - OutputColumn( - "quantitative", - include_in_quant_output=False, - include_in_qual_output=False, - ), - ) return cols diff --git a/compass/plugin/post_processing.py b/compass/plugin/post_processing.py index 6d9bbc711..6ae95c4a5 100644 --- a/compass/plugin/post_processing.py +++ b/compass/plugin/post_processing.py @@ -3,6 +3,11 @@ import inspect from pathlib import Path +from compass.utilities.finalize import ( + MAX_ORDINANCE_TEXT_CHARS, # ruff: ignore[unused-import] + trim_ordinance_text, +) + def add_document_name(db): """Add a document_name column to the database @@ -37,4 +42,7 @@ def add_document_name(db): and func.__module__ == __name__ and not name.startswith("_") } +# defined in compass.utilities.finalize (where it is also applied +# unconditionally), so it is registered here by hand +POST_PROCESSING_REGISTRY["trim_ordinance_text"] = trim_ordinance_text """[NOT PUBLIC API] Post-processing step registry""" diff --git a/compass/utilities/finalize.py b/compass/utilities/finalize.py index 975dae175..c289200d5 100644 --- a/compass/utilities/finalize.py +++ b/compass/utilities/finalize.py @@ -16,6 +16,10 @@ logger = logging.getLogger(__name__) +QUALITATIVE_UNITS = "str" +"""str: ``units`` value used to mark a qualitative ordinance row""" +MAX_ORDINANCE_TEXT_CHARS = 5000 +"""int: Max characters kept in the ``ordinance_text`` column""" def save_run_meta( @@ -94,8 +98,7 @@ def save_run_meta( "ORDINANCE_FILES_DIR": dirs.ordinance_files, "USAGE_FILE": dirs.out / "usage.json", "JURISDICTION_FILE": dirs.out / "jurisdictions.json", - "QUANT_DATA_FILE": dirs.out / "quantitative_ordinances.csv", - "QUAL_DATA_FILE": dirs.out / "quantitative_ordinances.csv", + "DATA_FILE": dirs.out / "ordinances.csv", } for name, file_path in manifest.items(): if file_path.exists(): @@ -177,18 +180,20 @@ def doc_infos_to_db(doc_infos, output_columns): def save_db(db, out_dir, output_columns): - """Write qualitative and quantitative ordinance outputs to disk + """Write the combined ordinance output to disk + + Qualitative and quantitative ordinances are written to a single + ``ordinances.csv`` file. The ``quantitative`` flag is retained as a + column so the two kinds of row remain distinguishable. Parameters ---------- db : pandas.DataFrame Ordinance dataset containing the full set of output columns, - plus the ``quantitative`` boolean flag that dictates output - routing. + plus the ``quantitative`` boolean flag. out_dir : path-like - Directory where ``qualitative_ordinances.csv`` and - ``quantitative_ordinances.csv`` should be written. The directory - is created by :class:`pathlib.Path` if necessary. + Directory where ``ordinances.csv`` should be written. The + directory is created by :class:`pathlib.Path` if necessary. output_columns : list List of expected output columns (as :class:`compass.plugin.interface.OutputColumn` instances) @@ -200,34 +205,63 @@ def save_db(db, out_dir, output_columns): Notes ----- Empty DataFrames short-circuit without creating output files. The - function respects the boolean ``quantitative`` column and assumes it - has already been sanitized by :func:`doc_infos_to_db`. + function assumes the boolean ``quantitative`` column has already + been sanitized by :func:`doc_infos_to_db`. """ if db.empty: return - qual_out_cols = [ - col.name for col in output_columns if col.include_in_qual_output - ] - quant_out_cols = [ - col.name for col in output_columns if col.include_in_quant_output - ] + out_cols = [col.name for col in output_columns] + + db = trim_ordinance_text(db) + db = _normalize_qualitative_rows(db) out_dir = Path(out_dir) - qual_db = db[~db["quantitative"]][qual_out_cols] - quant_db = db[db["quantitative"]][quant_out_cols] - qual_db.to_csv( - out_dir / "qualitative_ordinances.csv", - index=False, - encoding="utf-8-sig", - ) - quant_db.to_csv( - out_dir / "quantitative_ordinances.csv", + db[out_cols].to_csv( + out_dir / "ordinances.csv", index=False, encoding="utf-8-sig", ) +def trim_ordinance_text(db): + """Trim the ``ordinance_text`` column to a maximum character count + + LLMs occasionally return a very long excerpt for ``ordinance_text``, + which makes the output CSV unwieldy. Any excerpt longer than + :data:`MAX_ORDINANCE_TEXT_CHARS` is cut back to the last whole word + that fits and marked with a trailing ellipsis, matching the ellipsis + convention already used for elided text within an excerpt. + + Parameters + ---------- + db : pandas.DataFrame + The database containing extraction results, which may include + an ``"ordinance_text"`` column. + + Returns + ------- + pandas.DataFrame + The updated database, with any over-long ``ordinance_text`` + entries trimmed. Databases without that column are returned + unchanged. + """ + if db.empty or "ordinance_text" not in db.columns: + return db + + db["ordinance_text"] = db["ordinance_text"].apply(_trim_excerpt) + return db + + +def _trim_excerpt(text): + """Cut an excerpt to the last whole word within the char limit""" + if not isinstance(text, str) or len(text) <= MAX_ORDINANCE_TEXT_CHARS: + return text + + kept = text[:MAX_ORDINANCE_TEXT_CHARS].rsplit(" ", 1)[0].rstrip() + return f"{kept} ..." + + def _db_results(results, jurisdiction): """Extract results from doc attrs to DataFrame""" @@ -257,14 +291,47 @@ def _empirical_adjustments(db): def _formatted_db(db, parsed_cols): - """Format DataFrame for output""" + """Format DataFrame for output + + ``db`` must carry a ``quantitative`` column; every parser sets one + on each row it emits. It is not an output column, but it drives the + qualitative-row conventions applied here. + """ for col in parsed_cols: if col not in db.columns: db[col] = None db["quantitative"] = db["quantitative"].astype("boolean").fillna(True) + db = _normalize_qualitative_rows(db) ord_rows = ordinances_bool_index(db) - return db[ord_rows][parsed_cols].reset_index(drop=True) + # "quantitative" is not an output column, but it is needed + # downstream to label qualitative rows, so it rides along here + keep = [*parsed_cols, "quantitative"] + return db[ord_rows][keep].reset_index(drop=True) + + +def _normalize_qualitative_rows(db): + """Apply the qualitative-row conventions to a database + + Qualitative features have no measurable units, so ``units`` is set + to :data:`QUALITATIVE_UNITS` instead. That lets a reader tell the + two kinds of feature apart with a plain column comparison, since + they share a single output file and the ``quantitative`` flag is + not published. + + Their ``summary`` is also copied into ``value``. The schemas ask the + LLM for a summary and a null value on these rows, so filling the + value here means the model only ever writes the requirement once, + while readers still find it in the column they expect. + """ + qualitative = ~db["quantitative"] + db.loc[qualitative, "units"] = QUALITATIVE_UNITS + + # a numeric-only "value" column is float-typed, so it has to be + # widened before qualitative text can be written into it + db["value"] = db["value"].astype(object) + db.loc[qualitative, "value"] = db.loc[qualitative, "summary"] + return db def _extract_model_info_from_all_models(models): diff --git a/compass/utilities/parsing.py b/compass/utilities/parsing.py index 901285796..58402de6c 100644 --- a/compass/utilities/parsing.py +++ b/compass/utilities/parsing.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -_ORD_CHECK_COLS = ["value", "summary"] +_ORD_CHECK_COLS = ["value", "summary", "ordinance_text"] def is_pdf_doc(doc): diff --git a/examples/water_rights_demo/one-shot/water_rights_schema.json5 b/examples/water_rights_demo/one-shot/water_rights_schema.json5 index 3f66c1003..06011107d 100755 --- a/examples/water_rights_demo/one-shot/water_rights_schema.json5 +++ b/examples/water_rights_demo/one-shot/water_rights_schema.json5 @@ -10,7 +10,7 @@ "type": "array", "items": { "type": "object", - "required": ["feature", "value", "units", "section", "summary", "source"], + "required": ["feature", "value", "units", "section", "summary", "ordinance_text", "explanation", "source"], "additionalProperties": false, "properties": { "feature": { @@ -67,9 +67,17 @@ }, "summary": { "type": ["string", "null"], - "description": "Short requirement summary using direct excerpts/quotes whenever possible. Include key conditions, exemptions, qualifiers, application scope (permit/well/aquifer), and explanation for selected value. Null when no requirement exists.", + "description": "Short requirement summary capturing the gist along with all specifics and details. Do not copy the ordinance wording here; the verbatim excerpt belongs in ordinance_text. Include key conditions, exemptions, qualifiers, application scope (permit/well/aquifer), and explanation for selected value. Null when no requirement exists.", "default": null }, + "ordinance_text": { + "type": "string", + "description": "The complete relevant text excerpt containing the ordinance information, copy-pasted verbatim from the source document. The first sentence must be the one that corresponds to the extracted value and summary, reproduced in full without elision. After that first sentence, gather any other passages in the document that bear on this same requirement, whether they appear before or after it, and join them with an ellipsis ('...') in the order they appear in the document. Use the ellipsis to skip over text that is not relevant so the excerpt stays focused while still carrying every passage that supports the extracted value, units, or qualitative requirement. Every character outside of the ellipses must appear exactly as written in the source: no paraphrasing, summarizing, normalization, added context, or commentary. Must be a non-null, non-empty string." + }, + "explanation": { + "type": "string", + "description": "Brief rationale explaining why this row matches the selected feature under this schema. Reference the specific evidence in ordinance_text and how it supports the extracted value and units (or, for qualitative features, the inclusion criteria). Must be a non-null, non-empty string and must not use absence placeholders." + }, "source": { "type": ["number", "null"], "description": "Integer indicating the source index from which this information was pulled. If not applicable or unavailable, use null.", @@ -175,35 +183,44 @@ "value": true, "units": null, "section": "Rule 3.2 - Permit Required", - "summary": "'No person may drill a non-exempt well without first obtaining a permit from the District.' Exempt domestic wells are listed separately under Rule 3.5." + "summary": "A permit is required before drilling any non-exempt well. Domestic wells meeting the Rule 3.5 exemption are excluded.", + "ordinance_text": "No person may drill a non-exempt well without first obtaining a permit from the District.", + "explanation": "The clause states an unconditional permit obligation tied to drilling, which matches 'permit required'. The Rule 3.5 exemption is noted in summary because it narrows who the obligation applies to." }, { "feature": "annual extraction limits", "value": 2, "units": "acre-feet/year", "section": "Rule 8.1 - Production Limits", - "summary": "The district sets an explicit annual production cap of '2 acre-feet per acre per year' for this permit class; text also notes permit-specific adjustments by aquifer conditions." + "summary": "Annual production is capped at 2 acre-feet per acre. The cap is a default that the District may lower for individual permits when aquifer conditions warrant, so the stated figure is a ceiling rather than a guaranteed allocation.", + "ordinance_text": "Each non-exempt permit is limited to an annual production of 2 acre-feet per acre per year. ... The District may impose a lower annual limit on any individual permit where aquifer conditions warrant.", + "explanation": "The first sentence carries the numeric cap used for value and units. The second sentence is joined with an ellipsis because it qualifies that cap, and the intervening text about application procedures is not relevant to this feature." }, { "feature": "well spacing", "value": 500, "units": "feet", "section": "Rule 8.4 - Well Spacing", - "summary": "'A new non-exempt well must be at least 500 feet from any existing non-exempt well.' Reduced spacing may be allowed for low-capacity wells below stated gpm threshold." + "summary": "New non-exempt wells must sit at least 500 feet from any existing non-exempt well, though the District may approve a shorter separation for low-capacity wells producing under 25 gallons per minute.", + "ordinance_text": "A new non-exempt well must be at least 500 feet from any existing non-exempt well. ... The Board may approve a reduced spacing of not less than 250 feet for wells producing less than 25 gallons per minute.", + "explanation": "500 feet is the controlling general rule, so it is the extracted value. The 250-foot low-capacity exception is kept in ordinance_text and described in summary rather than replacing the value, since it applies only to a narrow class of wells." }, { "feature": "production cost", "value": "permit specific", "units": "dollars/acre-foot", "section": "Fee Schedule - Production Fees", - "summary": "Production fees are assessed by permit class and use category rather than one universal rate; schedule provides tiered dollar-per-acre-foot charges." + "summary": "There is no single production fee rate. Charges are tiered by permit class and use category, so the applicable rate depends on how the well is classified.", + "ordinance_text": "Production fees shall be assessed in accordance with the tiered dollar-per-acre-foot rates set out in the Fee Schedule for each permit class and use category.", + "explanation": "The ordinance sets a fee mechanism rather than one numeric rate, so value records that the charge is permit specific instead of inventing a single figure. This example shows summary explaining the structure in prose while ordinance_text stays verbatim." } ], "$instructions": { "general": [ "Extract only enacted district requirements, not proposed language or general background text.", - "Use direct excerpts/quotes in summary whenever possible.", - "If a feature has no requirement, set value, units, section, and summary to null or omit the feature row.", + "Use direct excerpts/quotes in ordinance_text; summary should restate the requirement in prose rather than quote it.", + "Whenever a feature row is emitted, ordinance_text must be the complete relevant excerpt from the source document for the requirement being extracted, copy-pasted verbatim. Its first sentence must be the one corresponding to the extracted value and summary, reproduced in full; after that, gather any other passages bearing on the same requirement, whether they appear earlier or later in the document, and join them with an ellipsis ('...') in document order. Unlike summary, every character outside the ellipses must appear exactly as written in the source, with no paraphrasing, normalization, added context, or commentary.", + "If a feature has no enacted requirement in the source text, omit the feature row entirely. Never emit a row with a null or empty ordinance_text to signal absence.", "When multiple values exist for one feature, choose the value applicable to the primary general rule and describe alternatives/conditions in summary.", "Preserve distinctions between drilling permits, extraction permits, transfer permits, and reporting obligations.", "Do not convert one-time application fees into production or transfer rate values." diff --git a/tests/python/unit/plugin/test_plugin_one_shot_base.py b/tests/python/unit/plugin/test_plugin_one_shot_base.py index 7b3768ac7..a4bf98793 100644 --- a/tests/python/unit/plugin/test_plugin_one_shot_base.py +++ b/tests/python/unit/plugin/test_plugin_one_shot_base.py @@ -21,6 +21,8 @@ def test_out_cols_from_config_uses_schema_output_properties(): "units", "location", "summary", + "ordinance_text", + "explanation", "section", "source", ], @@ -30,6 +32,8 @@ def test_out_cols_from_config_uses_schema_output_properties(): "units": {}, "location": {}, "summary": {}, + "ordinance_text": {}, + "explanation": {}, "section": {}, "source": {}, }, @@ -52,25 +56,58 @@ def test_out_cols_from_config_uses_schema_output_properties(): "units", "location", "summary", + "ordinance_text", + "explanation", "section", "year", "source", - "quantitative", ] - assert ( - next(col for col in cols if col.name == "value").include_in_qual_output - is False - ) - assert ( - next(col for col in cols if col.name == "units").include_in_qual_output - is False - ) - assert ( - next( - col for col in cols if col.name == "location" - ).include_in_qual_output - is True - ) + assert "quantitative" not in [col.name for col in cols] + + +def test_out_cols_from_config_keeps_summary(): + """Test summary reaches the output alongside ordinance_text""" + + config = { + "schema": { + "properties": { + "outputs": { + "items": { + "required": ["feature", "summary", "ordinance_text"], + "properties": { + "feature": {}, + "summary": {}, + "ordinance_text": {}, + }, + } + } + } + } + } + + col_names = [col.name for col in _out_cols_from_config(config)] + + assert "summary" in col_names + assert "ordinance_text" in col_names + + +def test_out_cols_from_config_keeps_explanation(): + """Test the explanation field reaches the output columns""" + + config = { + "schema": { + "properties": { + "outputs": { + "items": { + "required": ["feature", "explanation"], + "properties": {"feature": {}, "explanation": {}}, + } + } + } + } + } + + assert "explanation" in [col.name for col in _out_cols_from_config(config)] if __name__ == "__main__": diff --git a/tests/python/unit/plugin/test_plugin_post_processing.py b/tests/python/unit/plugin/test_plugin_post_processing.py new file mode 100644 index 000000000..50b9cde9e --- /dev/null +++ b/tests/python/unit/plugin/test_plugin_post_processing.py @@ -0,0 +1,81 @@ +"""COMPASS plugin post-processing tests""" + +from pathlib import Path + +import pandas as pd +import pytest + +from compass.plugin.post_processing import ( + MAX_ORDINANCE_TEXT_CHARS, + POST_PROCESSING_REGISTRY, + trim_ordinance_text, +) + + +def test_trim_ordinance_text_leaves_short_text_alone(): + """Test excerpts within the limit are untouched""" + + text = "Turbines shall not exceed 100 feet." + db = pd.DataFrame([{"ordinance_text": text}]) + + out = trim_ordinance_text(db) + + assert out.iloc[0]["ordinance_text"] == text + + +def test_trim_ordinance_text_trims_long_text(): + """Test over-long excerpts are cut back and marked""" + + long_text = "word " * (MAX_ORDINANCE_TEXT_CHARS // 2) + db = pd.DataFrame([{"ordinance_text": long_text}]) + + out = trim_ordinance_text(db) + trimmed = out.iloc[0]["ordinance_text"] + + assert len(trimmed) <= MAX_ORDINANCE_TEXT_CHARS + len(" ...") + assert trimmed.endswith(" ...") + # cut on a word boundary, so no partial word is left behind + assert not trimmed.removesuffix(" ...").endswith("wor") + + +def test_trim_ordinance_text_preserves_non_strings(): + """Test null entries survive trimming""" + + db = pd.DataFrame( + [{"ordinance_text": None}, {"ordinance_text": "short text"}] + ) + + out = trim_ordinance_text(db) + + assert out.iloc[0]["ordinance_text"] is None + assert out.iloc[1]["ordinance_text"] == "short text" + + +def test_trim_ordinance_text_without_column(): + """Test databases lacking the column pass through unchanged""" + + db = pd.DataFrame([{"feature": "Height"}]) + + out = trim_ordinance_text(db) + + assert list(out.columns) == ["feature"] + + +def test_trim_ordinance_text_with_empty_db(): + """Test empty databases pass through unchanged""" + + db = pd.DataFrame(columns=["ordinance_text"]) + + assert trim_ordinance_text(db).empty + + +def test_trim_ordinance_text_is_registered(): + """Test the step is discoverable as a post-processing step""" + + assert POST_PROCESSING_REGISTRY["trim_ordinance_text"] is ( + trim_ordinance_text + ) + + +if __name__ == "__main__": + pytest.main(["-q", "--show-capture=all", Path(__file__), "-rapP"]) diff --git a/tests/python/unit/utilities/test_utilities_finalize.py b/tests/python/unit/utilities/test_utilities_finalize.py index 643baae9b..3c0707f76 100644 --- a/tests/python/unit/utilities/test_utilities_finalize.py +++ b/tests/python/unit/utilities/test_utilities_finalize.py @@ -72,7 +72,7 @@ def test_save_run_meta_writes_meta_file(tmp_path, monkeypatch): (tmp_path / "usage.json").write_text("{}", encoding="utf-8") (tmp_path / "jurisdictions.json").write_text("{}", encoding="utf-8") - (tmp_path / "quantitative_ordinances.csv").write_text( + (tmp_path / "ordinances.csv").write_text( "header\n", encoding="utf-8", ) @@ -126,7 +126,7 @@ def test_save_run_meta_writes_meta_file(tmp_path, monkeypatch): assert manifest["ORDINANCE_FILES_DIR"] == "ordinances" assert manifest["USAGE_FILE"] == "usage.json" assert manifest["JURISDICTION_FILE"] == "jurisdictions.json" - assert manifest["QUAL_DATA_FILE"] == "quantitative_ordinances.csv" + assert manifest["DATA_FILE"] == "ordinances.csv" assert manifest["META_FILE"] == "meta.json" model_info = meta["models"][0] @@ -393,7 +393,9 @@ def test_doc_infos_to_db_compiles_and_formats(tmp_path): """Compile document info into formatted DataFrame""" empty_csv = tmp_path / "empty.csv" - pd.DataFrame(columns=["feature", "summary"]).to_csv(empty_csv, index=False) + pd.DataFrame(columns=["feature", "ordinance_text"]).to_csv( + empty_csv, index=False + ) valid_csv = tmp_path / "valid.csv" pd.DataFrame( @@ -401,11 +403,13 @@ def test_doc_infos_to_db_compiles_and_formats(tmp_path): { "feature": "Height Limit", "summary": "Maximum 100 ft", + "ordinance_text": "No turbine shall exceed 100 ft.", "value": 100, "units": "ft", "adder": 300, "source": "http://example.com/valid", "year": 2022, + "quantitative": True, } ] ).to_csv(valid_csv, index=False) @@ -448,10 +452,10 @@ def test_doc_infos_to_db_compiles_and_formats(tmp_path): def test_save_db_writes_csvs(tmp_path): - """Split qualitative and quantitative outputs""" + """Write qualitative and quantitative rows to one combined file""" out_cols = [col.name for col in COMPASSWindExtractor.OUTPUT_COLUMNS] - row_true = dict.fromkeys(out_cols) + row_true = dict.fromkeys([*out_cols, "quantitative"]) row_true.update( { "county": "County A", @@ -462,7 +466,8 @@ def test_save_db_writes_csvs(tmp_path): "feature": "Height", "value": 100, "units": "ft", - "summary": "Maximum height", + "ordinance_text": "Turbines shall not exceed 100 ft.", + "explanation": "States an explicit numeric height cap.", "year": 2020, "source": "http://source", "quantitative": True, @@ -473,7 +478,8 @@ def test_save_db_writes_csvs(tmp_path): row_false.update( { "feature": "Setback", - "summary": "Setback distance", + "ordinance_text": "Turbines shall be set back from lot lines.", + "explanation": "States a setback requirement without a number.", "quantitative": False, } ) @@ -481,30 +487,140 @@ def test_save_db_writes_csvs(tmp_path): df = pd.DataFrame([row_true, row_false]) finalize.save_db(df, tmp_path, COMPASSWindExtractor.OUTPUT_COLUMNS) - quant_path = tmp_path / "quantitative_ordinances.csv" - qual_path = tmp_path / "qualitative_ordinances.csv" - assert quant_path.exists() - assert qual_path.exists() - - quant = pd.read_csv(quant_path) - qual = pd.read_csv(qual_path) - expected_cols = [ - col.name - for col in COMPASSWindExtractor.OUTPUT_COLUMNS - if col.include_in_quant_output - ] - assert list(quant.columns) == expected_cols - assert len(quant) == 1 + out_path = tmp_path / "ordinances.csv" + assert out_path.exists() + assert not (tmp_path / "quantitative_ordinances.csv").exists() + assert not (tmp_path / "qualitative_ordinances.csv").exists() - expected_cols = [ - col.name - for col in COMPASSWindExtractor.OUTPUT_COLUMNS - if col.include_in_qual_output - ] - assert list(qual.columns) == expected_cols - assert len(qual) == 1 - assert quant.iloc[0]["feature"] == "Height" - assert qual.iloc[0]["feature"] == "Setback" + out = pd.read_csv(out_path) + assert list(out.columns) == out_cols + assert len(out) == 2 + + # both kinds of row land in the same file, told apart by the units + # sentinel; the internal quantitative flag is not published + assert "quantitative" not in out.columns + quant_rows = out[out["units"] != finalize.QUALITATIVE_UNITS] + qual_rows = out[out["units"] == finalize.QUALITATIVE_UNITS] + assert list(quant_rows["feature"]) == ["Height"] + assert list(qual_rows["feature"]) == ["Setback"] + + # value/units are retained for qualitative rows + assert "summary" in out.columns + assert "ordinance_text" in out.columns + assert "explanation" in out.columns + assert quant_rows.iloc[0]["units"] == "ft" + + +def test_save_db_labels_qualitative_units(tmp_path): + """Mark qualitative rows with the units sentinel""" + + out_cols = [col.name for col in COMPASSWindExtractor.OUTPUT_COLUMNS] + + def _row(feature, quantitative, units): + row = dict.fromkeys([*out_cols, "quantitative"]) + row.update( + { + "feature": feature, + "quantitative": quantitative, + "units": units, + "ordinance_text": "text", + } + ) + return row + + df = pd.DataFrame( + [ + _row("Height", True, "ft"), + _row("Decommissioning", False, None), + # the LLM sometimes invents units for a qualitative feature + _row("Signage", False, "bogus"), + ] + ) + finalize.save_db(df, tmp_path, COMPASSWindExtractor.OUTPUT_COLUMNS) + + out = pd.read_csv(tmp_path / "ordinances.csv") + by_feature = out.set_index("feature")["units"] + + assert by_feature["Height"] == "ft" + assert by_feature["Decommissioning"] == finalize.QUALITATIVE_UNITS + assert by_feature["Signage"] == finalize.QUALITATIVE_UNITS + + # selecting one kind of feature is a plain column comparison, and the + # internal quantitative flag stays out of the published file + assert "quantitative" not in out.columns + qual = out[out["units"] == finalize.QUALITATIVE_UNITS] + assert set(qual["feature"]) == {"Decommissioning", "Signage"} + + +def test_save_db_mirrors_summary_into_value_for_qualitative(tmp_path): + """Copy summary into value on qualitative rows""" + + out_cols = [col.name for col in COMPASSWindExtractor.OUTPUT_COLUMNS] + + def _row(feature, quantitative, value, summary): + row = dict.fromkeys([*out_cols, "quantitative"]) + row.update( + { + "feature": feature, + "quantitative": quantitative, + "value": value, + "summary": summary, + "units": "ft" if quantitative else None, + "ordinance_text": "quoted text", + } + ) + return row + + # the schemas ask for a null value on qualitative rows, so the + # requirement arrives in summary only + df = pd.DataFrame( + [ + _row("Height", True, 100, "Max 100 ft, 80 ft in AG district."), + _row("Decommissioning", False, None, "Remove within 12 months."), + _row("Signage", False, None, "Warning signs only."), + ] + ) + finalize.save_db(df, tmp_path, COMPASSWindExtractor.OUTPUT_COLUMNS) + + out = pd.read_csv(tmp_path / "ordinances.csv").set_index("feature") + + # quantitative rows keep their number and their prose restatement + assert out.loc["Height", "value"] == "100.0" + assert out.loc["Height", "summary"] == ( + "Max 100 ft, 80 ft in AG district." + ) + + # qualitative rows get the summary text filled into value + assert out.loc["Decommissioning", "value"] == "Remove within 12 months." + assert out.loc["Signage", "value"] == "Warning signs only." + + # neither column is left blank + assert out["value"].notna().all() + assert out["summary"].notna().all() + + +def test_save_db_trims_long_ordinance_text(tmp_path): + """Trim over-long excerpts on the way out to disk""" + + out_cols = [col.name for col in COMPASSWindExtractor.OUTPUT_COLUMNS] + row = dict.fromkeys([*out_cols, "quantitative"]) + row.update( + { + "feature": "Height", + "ordinance_text": "word " * 4000, + "quantitative": True, + } + ) + + finalize.save_db( + pd.DataFrame([row]), tmp_path, COMPASSWindExtractor.OUTPUT_COLUMNS + ) + + written = pd.read_csv(tmp_path / "ordinances.csv") + text = written.iloc[0]["ordinance_text"] + + assert len(text) < 4000 * len("word ") + assert text.endswith(" ...") def test_save_db_with_empty_df(tmp_path): @@ -513,8 +629,7 @@ def test_save_db_with_empty_df(tmp_path): out_cols = [col.name for col in COMPASSWindExtractor.OUTPUT_COLUMNS] empty_df = pd.DataFrame(columns=out_cols) finalize.save_db(empty_df, tmp_path, COMPASSWindExtractor.OUTPUT_COLUMNS) - assert not (tmp_path / "qualitative_ordinances.csv").exists() - assert not (tmp_path / "quantitative_ordinances.csv").exists() + assert not (tmp_path / "ordinances.csv").exists() def test_db_results_populates_jurisdiction_fields(): @@ -563,13 +678,16 @@ def test_formatted_db_adds_missing_columns(): [ { "feature": "Height", - "summary": "Max height", + "ordinance_text": "Max height is 100 ft.", + "quantitative": True, } ] ) expected_cols = [col.name for col in COMPASSWindExtractor.OUTPUT_COLUMNS] formatted = finalize._formatted_db(df, expected_cols) - assert list(formatted.columns) == expected_cols + # "quantitative" rides along for downstream labeling even though it is + # not one of the published output columns + assert list(formatted.columns) == [*expected_cols, "quantitative"] assert len(formatted) == 1 assert bool(formatted.iloc[0]["quantitative"]) is True