11"""Ordinance date extraction logic"""
22
33import logging
4- import asyncio
54from datetime import datetime
6- from collections import Counter
75
86from compass .utilities .enums import LLMUsageCategory
97from compass .utilities .parsing import raw_pages_from_doc
108
119
1210logger = 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
1819class 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"\n The document was downloaded from this URL: { url } "
83+ content += f"\n \n Ordinance 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
0 commit comments