Skip to content

Commit ef0e438

Browse files
authored
Unified single-call date extraction (#466)
* Try unified approach * Fix prompt * Update dev eval results for unified date extraction * Cap pages, fix date tests and docstring for unified extraction * Fix date prompt * Parallelize date eval with xdist and add per-jurisdiction results + JSON breakdown * Simplify date extraction system prompt * Trim eval commentary and extract PerJurisdictionResults to utilities * Drop regression gate and CSV breakdown; JSON is the record
1 parent 9a4aa88 commit ef0e438

13 files changed

Lines changed: 1117 additions & 415 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ venv.bak/
2121
# Per-case eval run logs (debug artifacts, not committed)
2222
evals/results/*/logs/
2323

24+
# Per-jurisdiction eval result shards (aggregated into the breakdown CSV)
25+
evals/results/*/per_jurisdiction/
26+
2427
# Documentation
2528
docs/_build/
2629
docs/source/_autosummary/

‎compass/extraction/date.py‎

Lines changed: 74 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,34 @@
11
"""Ordinance date extraction logic"""
22

33
import logging
4-
import asyncio
54
from datetime import datetime
6-
from collections import Counter
75

86
from compass.utilities.enums import LLMUsageCategory
97
from compass.utilities.parsing import raw_pages_from_doc
108

119

1210
logger = logging.getLogger(__name__)
1311

14-
# These domains contain the collection date in URL, not enactment date
15-
_BANNED_DATE_DOMAINS = ["https://energyzoning.org"]
12+
# Cap the text sent in a single date-extraction call. Enactment dates
13+
# live in the preamble (front) and signature/adoption block (back), so
14+
# keep the first and last pages and drop the middle for long documents.
15+
_MAX_HEAD_PAGES = 20
16+
_MAX_TAIL_PAGES = 10
1617

1718

1819
class DateExtractor:
1920
"""Helper class to extract date info from document"""
2021

2122
SYSTEM_MESSAGE = (
2223
"You are a legal scholar that reads ordinance text and extracts "
23-
"structured date information. "
24-
"Return your answer as a dictionary in JSON format (not markdown). "
25-
"Your JSON file must include exactly four keys. The first "
26-
"key is 'explanation', which contains a short summary of the most "
27-
"relevant date information you found in the text. The second key is "
28-
"'year', which should contain an integer value that represents the "
29-
"latest year this ordinance was enacted/updated, or null if that "
30-
"information cannot be found in the text. The third key is 'month', "
31-
"which should contain an integer value that represents the latest "
32-
"month of the year this ordinance was enacted/updated, or null if "
33-
"that information cannot be found in the text. The fourth key is "
34-
"'day', which should contain an integer value that represents the "
35-
"latest day of the month this ordinance was enacted/updated, or null "
36-
"if that information cannot be found in the text. Only provide values "
37-
"if you are confident that they represent the latest date this "
38-
"ordinance was enacted/updated"
24+
"a single date. The date you report is the latest year the "
25+
"ordinance was enacted or ammended or became effective. If no such "
26+
"date is available return null."
27+
"Return your answer in JSON format like this: "
28+
'{"explanation": TEXT, "year": YY, "month": MM, "day": DD}'
29+
"Where explanation contains a short summary/explanation of "
30+
"the date information you found, including the exact text the "
31+
"date is based on."
3932
)
4033
"""System message for date extraction LLM calls"""
4134

@@ -58,6 +51,10 @@ def __init__(self, json_llm_caller, text_splitter=None):
5851
async def parse(self, doc):
5952
"""Extract date (year, month, day) from doc
6053
54+
The full document text is read in a single LLM call. The
55+
document's ``source`` URL, if any, is passed along as a hint,
56+
but the document text is the source of truth.
57+
6158
Parameters
6259
----------
6360
doc : BaseDocument
@@ -66,88 +63,72 @@ async def parse(self, doc):
6663
Returns
6764
-------
6865
tuple
69-
3-tuple containing year, month, day, or ``None`` if any of
70-
those are not found.
66+
3-tuple of (year, month, day). Any element that cannot be
67+
determined is ``None``.
7168
"""
69+
raw_pages = [
70+
page
71+
for page in raw_pages_from_doc(doc, self.text_splitter)
72+
if page
73+
]
74+
raw_pages = _trim_pages(raw_pages)
75+
text = "\n\n".join(raw_pages)
76+
if not text:
77+
return None, None, None
78+
79+
content = "Please extract the enactment date for this ordinance."
7280
url = doc.attrs.get("source")
73-
can_check_url_for_date = url and not any(
74-
sub_str in url for sub_str in _BANNED_DATE_DOMAINS
81+
if url:
82+
content += f"\nThe document was downloaded from this URL: {url}"
83+
content += f"\n\nOrdinance text:\n{text}"
84+
85+
response = await self.jlc.call(
86+
sys_msg=self.SYSTEM_MESSAGE,
87+
content=content,
88+
usage_sub_label=LLMUsageCategory.DATE_EXTRACTION,
7589
)
76-
if can_check_url_for_date:
77-
logger.debug("Checking URL for date: %s", url)
78-
response = await self.jlc.call(
79-
sys_msg=self.SYSTEM_MESSAGE,
80-
content=(
81-
"Please extract the date from the URL for this "
82-
f"ordinance, if possible:\n{url}"
83-
),
84-
usage_sub_label=LLMUsageCategory.DATE_EXTRACTION,
90+
if response:
91+
logger.debug(
92+
"Date extraction explanation: %s",
93+
response.get("explanation"),
8594
)
86-
if response:
87-
date = _parse_date([response])
88-
logger.debug("Parsed date from URL: %s", date)
89-
return date
95+
date = _parse_date(response)
96+
logger.debug("Parsed date: %s", date)
97+
return date
9098

91-
raw_pages = raw_pages_from_doc(doc, self.text_splitter)
92-
if not raw_pages:
93-
return None, None, None
94-
95-
outer_task_name = asyncio.current_task().get_name()
96-
date_extractions = [
97-
asyncio.create_task(
98-
self.jlc.call(
99-
sys_msg=self.SYSTEM_MESSAGE,
100-
content=(
101-
f"Please extract the date for this ordinance:\n{text}"
102-
),
103-
usage_sub_label=LLMUsageCategory.DATE_EXTRACTION,
104-
),
105-
name=outer_task_name,
106-
)
107-
for text in raw_pages
108-
if text
109-
]
110-
all_years = await asyncio.gather(*date_extractions)
111-
return _parse_date([y for y in all_years if y])
11299

100+
def _trim_pages(pages):
101+
"""Keep the head and tail pages, dropping the middle if too long"""
102+
if len(pages) <= _MAX_HEAD_PAGES + _MAX_TAIL_PAGES:
103+
return pages
104+
return pages[:_MAX_HEAD_PAGES] + pages[-_MAX_TAIL_PAGES:]
113105

114-
def _parse_date(json_list):
115-
"""Parse all date elements
116106

117-
True date is determined to be the most frequent date. In the case of
118-
a tie, the latest date is chosen.
119-
"""
120-
if not json_list:
107+
def _parse_date(date_info):
108+
"""Validate and return the (year, month, day) from a response"""
109+
if not date_info:
121110
return None, None, None
122111

123-
years = _parse_date_element(
124-
json_list,
125-
key="year",
126-
max_len=4,
127-
min_val=2000,
128-
max_val=datetime.now().year,
129-
)
130-
months = _parse_date_element(
131-
json_list, key="month", max_len=2, min_val=1, max_val=12
132-
)
133-
days = _parse_date_element(
134-
json_list, key="day", max_len=2, min_val=1, max_val=31
112+
year = _validated_element(
113+
date_info, key="year", min_val=1950, max_val=datetime.now().year + 1
135114
)
115+
month = _validated_element(date_info, key="month", min_val=1, max_val=12)
116+
day = _validated_element(date_info, key="day", min_val=1, max_val=31)
117+
return year, month, day
136118

137-
date_elements = Counter(zip(years, months, days, strict=False))
138-
date = max(date_elements, key=lambda date: (date_elements[date], date))
139-
return tuple(None if d < 0 else d for d in date)
140-
141-
142-
def _parse_date_element(json_list, key, max_len, min_val, max_val):
143-
"""Parse out a single date element"""
144-
date_elements = [info.get(key) for info in json_list]
145-
logger.debug("key=%r, date_elements=%r", key, date_elements)
146-
return [
147-
int(y)
148-
if y is not None
149-
and len(str(y)) <= max_len
150-
and (min_val <= int(y) <= max_val)
151-
else -1 * float("inf")
152-
for y in date_elements
153-
]
119+
120+
def _validated_element(date_info, key, min_val, max_val):
121+
"""Return a single date element if it falls within the valid range
122+
123+
Acts as a cheap safety net against an out-of-range or malformed
124+
value in the model response.
125+
"""
126+
value = date_info.get(key)
127+
logger.debug("key=%r, value=%r", key, value)
128+
if value is None:
129+
return None
130+
try:
131+
value = int(float(value))
132+
except (TypeError, ValueError):
133+
return None
134+
return value if min_val <= value <= max_val else None

‎evals/README.md‎

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
Accuracy/quality evaluations of COMPASS extraction against real ordinance
44
documents. Each suite is a `test_run_<name>_evals.py` file that drives a
5-
specific extractor end-to-end and writes a breakdown + metrics file to
6-
`results/`. A regression gate (run by the test module's autouse fixture)
7-
fails the run if the committed baseline gets worse.
5+
specific extractor end-to-end and writes a metrics file (and, for dev, a
6+
per-case breakdown) to `results/`. Cases run in parallel under
7+
pytest-xdist. There is no regression gate — accuracy changes show up as
8+
diffs in the committed `results/` files.
89

910
## Run
1011

@@ -30,8 +31,7 @@ Each dataset is split into a frequently-run **dev** set and a sacred
3031
| --- | --- | --- |
3132
| Purpose | iterate, tune prompts/logic, debug failures | unbiased estimate of true performance |
3233
| Cadence | run frequently during development | run before a release |
33-
| Regression gate | yes — fails on aggregate or per-row regression | no — unbiased read, just prints + writes JSON |
34-
| Per-case breakdown | written + logged | hidden (no breakdown CSV, no per-case logs) |
34+
| Per-case breakdown | written + logged | hidden (metrics only, no per-case detail) |
3535

3636
The held-out set only gives an **honest** read if we *don't* tune against it:
3737

@@ -43,23 +43,24 @@ The held-out set only gives an **honest** read if we *don't* tune against it:
4343
before a release), not a development loop.
4444

4545
The harness helps enforce this: a `--held-out` run writes **only summary
46-
metrics** (no per-case breakdown), per-case predictions are not logged,
47-
and there is no regression gate — so there is nothing to eyeball or tune
48-
against.
46+
metrics** (no per-case breakdown, no explanations, no per-case logs) — so
47+
there is nothing to eyeball or tune against.
4948

5049
## Layout
5150

5251
```
5352
test_run_<name>_evals.py # one eval suite per extractor (e.g. test_run_date_extraction_evals.py)
54-
conftest.py # registers the --held-out pytest flag
53+
conftest.py # --held-out flag; session hooks that clear + aggregate results
5554
utilities/ # shared, eval-agnostic plumbing
5655
base.py # Result schema, SUCCESS/FAILURE, classify, load_doc
5756
metrics.py # compute_metrics, wilson_ci (pure math, no I/O)
58-
reports.py # report_evals + load_baseline_failing + regressed_rows (I/O + formatting)
57+
reports.py # report_evals + PerJurisdictionResults (I/O + formatting)
5958
results/
60-
dev/<name>_evals.json # committed baseline metrics (gate reads these)
61-
dev/<name>_evals_breakdown.csv # committed per-case dev breakdown
62-
held_out/<name>_evals.json # committed baseline held-out metrics (no per-case detail)
59+
dev/<name>_evals.json # committed metrics
60+
dev/<name>_evals_breakdown.json # committed per-case dev breakdown (+ explanations)
61+
dev/per_jurisdiction/ # one Result JSON per case (xdist shards; gitignored)
62+
dev/logs/ # per-jurisdiction run logs (gitignored)
63+
held_out/<name>_evals.json # committed held-out metrics (no per-case detail)
6364
data/
6465
dev/<tech>/
6566
manifest.json5 # [{state, county, subdivision, jurisdiction_type, file, source, expected: {year, ...}}, ...]
@@ -78,27 +79,30 @@ exists" — the extractor should return no year for that document.
7879

7980
## How a suite is wired
8081

81-
A `test_run_<name>_evals.py` file owns three pieces:
82+
Cases run in parallel across xdist worker processes, so results can't
83+
live in a module-level list (each worker is its own process). Instead:
8284

8385
1. **`pytest_generate_tests(metafunc)`** reads `--held-out`, loads the
8486
right `manifest.json5`, and parametrizes the test's `case` argument.
8587
It also stamps each case with `case["fp"]` (the resolved document
8688
path), so the test body never has to know which dataset it came from.
8789
2. **`@pytest.mark.evals` test function** runs the extractor on one
88-
case and appends a `Result` to the module-level `RESULTS` list.
89-
3. **Module-scoped autouse teardown fixture** calls
90-
`report_evals(request, EVAL_NAME, RESULTS, results_dir,
91-
write_breakdown=not held_out)` to compute metrics, write the
92-
artifacts, and snapshot baselines. The returned dict's
93-
`baseline_failing` / `fails_now` / `regressed_rows` fields drive
94-
each suite's own gate -- the `reports` module does **not** decide
95-
what counts as a regression. (Held-out runs skip the gate entirely.)
90+
case and writes its `Result` to its own
91+
`results/<set>/per_jurisdiction/<jurisdiction>.json` file (one file
92+
per case, so concurrent workers never collide) via
93+
`utilities.PerJurisdictionResults`.
94+
3. **`conftest.py` session hooks** (controller only): `sessionstart`
95+
clears stale per-jurisdiction files and logs; `sessionfinish` reads
96+
every per-jurisdiction file back, then calls the suite's `report(...)`
97+
to write the metrics JSON and (dev only) the explanation-rich
98+
breakdown JSON.
9699

97100
## Adding an eval suite
98101

99102
1. Drop ground-truth docs + a `manifest.json5` under `data/{dev,held-out}/<tech>/`.
100103
2. Copy `test_run_date_extraction_evals.py` as a starting point. Swap in
101104
your extractor function and the `expected.<feature>` key you compare
102105
against.
103-
3. First run sets the baseline; commit the resulting
104-
`results/{dev,held_out}/<name>_evals.json` (and the dev breakdown `.csv`).
106+
3. Commit the resulting `results/{dev,held_out}/<name>_evals.json` and
107+
the dev `<name>_evals_breakdown.json`; accuracy changes then show up
108+
as diffs on those files.

‎evals/conftest.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,55 @@ def pytest_addoption(parser):
1010
default=False,
1111
help=("Run the eval against the held-out dataset"),
1212
)
13+
14+
15+
def _is_controller(config):
16+
# xdist sets ``workerinput`` only on workers; its absence is the
17+
# controller, which outlives all workers and aggregates their results.
18+
return not hasattr(config, "workerinput")
19+
20+
21+
def _date_eval():
22+
"""Import the date eval module, or ``None`` if it isn't this run
23+
24+
The session hooks need a few eval-specific entry points
25+
(``per_jurisdiction_results``, ``clear_logs``, ``report``); the
26+
generic results I/O lives in ``utilities.PerJurisdictionResults``.
27+
"""
28+
try:
29+
import test_run_date_extraction_evals as module # noqa: PLC0415
30+
except ImportError:
31+
return None
32+
return module
33+
34+
35+
def pytest_sessionstart(session):
36+
"""Clear stale results and logs before a run (controller only)"""
37+
config = session.config
38+
if not _is_controller(config):
39+
return
40+
module = _date_eval()
41+
if module is None:
42+
return
43+
held_out = config.getoption("--held-out")
44+
module.per_jurisdiction_results(held_out).clear()
45+
module.clear_logs(held_out)
46+
47+
48+
def pytest_sessionfinish(session, exitstatus):
49+
"""Aggregate per-jurisdiction results and write reports (controller only)
50+
51+
Under xdist the per-case results are scattered across workers, so
52+
reading the per-jurisdiction files here gives the full set.
53+
"""
54+
config = session.config
55+
if not _is_controller(config):
56+
return
57+
module = _date_eval()
58+
if module is None:
59+
return
60+
61+
held_out = config.getoption("--held-out")
62+
results = module.per_jurisdiction_results(held_out).load()
63+
if results:
64+
module.report(session, results, held_out)

‎evals/data/dev/solar/manifest.json5‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@
368368
"source": "https://www.townofglenville.org/home/files/gecc-solar-attachments",
369369
"document_satus": "Final",
370370
"expected": {
371-
"year": 2023
371+
"year": 2021
372372
}
373373
},
374374
{

0 commit comments

Comments
 (0)