
    tj9                       U d Z ddlmZ ddlZddlZddlZddlmZ ddlm	Z	mZm
Z
 ddlmZ ddlmZ ddlmZ dd	lmZ ed
z  dz  Zed
z  dz  Zed
z  dz  Zedz  dz  ZdZ ed      D  ci c]  \  } }|j3                         |  c}} Zdddddddddddddj7                         D ]
  \  ZZeee<     ej<                  d      Z ej<                  dej@                        Z! ej<                  d       Z" ej<                  d!      Z#i Z$d"e%d#<   d6d$Z&d7d%Z'd8d&Z(d9d'Z)d7d(Z*d:d)Z+e G d* d+             Z,d;d,Z-d<d-Z.d=d.Z/d>d/Z0d?d0Z1d@d1Z2dAd2Z3dBd3Z4dCd4Z5dDd5Z6yc c}} w )Ea?  Deterministic inbox classification and conservative date extraction.

No model calls. Ever. A model may add a label in a later pass; nothing here may be
replaced by one, because the whole value of the register is that a date captured in
January still surfaces in March regardless of what anything was feeling that day.
    )annotationsN)	dataclass)datedatetimetimezone)	parseaddr)Path)Any)ROOTconfigz
rules.tomlzcritical-senders.txtzbulk-senders.txtdatafindingsi  ) JanuaryFebruaryMarchAprilMayJuneJulyAugust	SeptemberOctoberNovemberDecember                     	   
         )janfebmaraprjunjulaugsepseptoctnovdecz-\b(20\d{2})-(0[1-9]|1[0-2])-([0-2]\d|3[01])\bz\b([0-3]?\d)(?:st|nd|rd|th)?\s+(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)(?:\s+(20\d{2}))?\bz/\b([0-3]?\d)[/-]([0-3]?\d)[/-](20\d{2}|\d{2})\bz\b([0-3]?\d)[/-]([0-3]?\d)\bzdict[Path, tuple[float, Any]]_CACHEc                    	 | j                         j                  }t        j	                  |       }||d   |k(  r|d   S  ||       }||ft        | <   |S # t        $ r d}Y Fw xY w)Ng      r   r   )statst_mtimeFileNotFoundErrorr3   get)pathparsestamphitvalues        /root/personal-cos/rules.py_cachedr?   `   sq    		$$ **T
C
3q6U?1v$KE5>F4LL  s   A A#"A#c                2   | j                         s
t               S | j                  d      j                         D ch c]O  }|j	                         r=|j                         j                  d      s|j	                         j                         Q c}S c c}w )Nutf-8encoding#)existsset	read_text
splitlinesstriplstrip
startswithlower)r9   lines     r>   _parse_valuesrN   m   ss    ;;=u NNGN4??A::< 8 8 = 	

  s   ABc                   t        j                  | j                  d            }dD ci c]$  }||d   |   D cg c]  }|t        |      f c}& c}}|d   d   D cg c]  }|t        |      f c}d}|S c c}w c c}}w c c}w )NrA   rB   moneylegalsecurityactreviewterms)rT   rU   )tomllibloadsrG   _word_regex)r9   r   grouptermcompileds        r>   _parse_rulesr]   w   s    ]]4>>7>;<F 8
 &-:NO$T;t,-OO
 :@9I'9RSD+d+,SH O P
 Ts   BA<B!B<Bc                z    t        j                  dt        j                  | j                               z   dz         S )zMatch the term as a whole word, not as a substring.

    Substring matching made 'fine' fire on 'define' and 'refined' (92 of its 140
    hits), 'visa' fire on the card brand, and 'invoice' fire inside 'invoiced'
    (45 of 89). Measured 2026-07-29.
    z(?<!\w)z(?!\w))recompileescaperL   )r[   s    r>   rY   rY      s,     ::j299TZZ\#::YFGG    c                "    t        | t              S N)r?   rN   )r9   s    r>   _load_valuesre      s    4''rb   c                 *    t        t        t              S rd   )r?   
RULES_PATHr]    rb   r>   _load_rulesri      s    :|,,rb   c                  @    e Zd ZU ded<   ded<   ded<   ded<   ded<   y	)
	DateMatchstroriginalz
str | None
normalizedbool	ambiguouswithin_30_daysfuture_beyond_30_daysN)__name__
__module____qualname____annotations__rh   rb   r>   rk   rk      s    MOrb   rk   c                <    	 t        | ||      S # t        $ r Y y w xY wrd   )r   
ValueError)yearmonthdays      r>   
_safe_dater|      s(    D%%% s    	c                    t        j                  t        j                        j	                         }|t        | d |dd      S ||z
  j                  }t        | |j                         |d|cxk  xr dk  nc |dkD        S )NFr      )r   nowr   utcr   rk   days	isoformat)rm   parsedrp   todaydeltas        r>   _classify_dater      sw    LL&++-E~4E5AAe^!!E	Ub
 rb   c           
       
 g }g }t         j                  |       D ]  
t        t        
j	                  d            t        
j	                  d            t        
j	                  d                  }|j                  t        
j	                  d      |d             |j                  
j                                 t        j                  |       D ]6  
t        
fd|D              r|j                  
j                                
j	                  d      s,|j                  t        
j	                  d      dd             u
j	                  d      j                         }t        j                  |t        j                  |dd             }|rFt        t        
j	                  d            t        |      t        
j	                  d                  nd}|j                  t        
j	                  d      ||du              9 t        j                  |       D ]8  
t        
fd	|D              r|j                  
j                                t        
j	                  d            t        
j	                  d            }}
j	                  d      }t        |      dk(  rt        |      d
z   n
t        |      }	|dk  r1|dk  r,|j                  t        
j	                  d      dd             |dkD  r|dk  rt        |	||      }n|dkD  r|dk  rt        |	||      }nd}|j                  t        
j	                  d      ||du              ; t        j                  |       D ]a  
t        
fd|D              r|j                  
j                                |j                  t        
j	                  d      dd             c |S )zFind dates that state their own year. Everything else is ambiguous.

    A date without a year is not a deadline, it is a guess about a deadline. Guessing
    is what filled the register with 2027.
    r   r   r   r   Fc              3  t   K   | ]/  \  }}j                         |k  xr j                         |kD   1 y wrd   startend.0r   r   matchs      r>   	<genexpr>z extract_dates.<locals>.<genexpr>   3     Vzucu{{}s":uyy{U'::V   58NTc              3  t   K   | ]/  \  }}j                         |k  xr j                         |kD   1 y wrd   r   r   s      r>   r   z extract_dates.<locals>.<genexpr>   r   r   i  r&   c              3  t   K   | ]/  \  }}j                         |k  xr j                         |kD   1 y wrd   r   r   s      r>   r   z extract_dates.<locals>.<genexpr>   r   r   )ISO_DATEfinditerr|   intrZ   appendr   spanWRITTEN_DATEanyrL   MONTHSr8   NUMERIC_DATElenBARE_NUMERIC_DATE)textresultsoccupiedr   	month_keyrz   firstsecond	year_textry   r   s             @r>   extract_datesr      s	     "G&(H""4( &CA/U[[^1Dc%++VW.FYZ~ekk!nfeDE

%&
 &&t, OVXVV

%{{1~NN>%++a.$EFKKN((*	

9fjj2A&?@  s5;;q>*CJEKKN8KL 	
 	~ekk!nffnMNO" &&t, OVXVV

%EKKN+SQ-@vKKN	(+I!(;s9~$YB;6R<NN>%++a.$EF2:&B,fe4Fb[Ub[eV4FF~ekk!nffnMN#O& #++D1 CVXVV

%~ekk!ndDAB	C Nrb   c                f    | j                         t        fdt        t              D              S )a}  Marketing senders whose copy is full of the words the ACT rules hunt for.

    Measured 2026-07-29: 1,268 of 1,746 real messages over six months came from these,
    and their 'final notice' / 'last chance' / 'renewal' phrasing fires the money and
    date rules constantly. Suppressing by sender is cheaper and far more accurate than
    trying to out-clever the copywriters.
    c              3  &   K   | ]  }|v  
 y wrd   rh   )r   patternlowereds     r>   r   z"_is_bulk_sender.<locals>.<genexpr>   s     Qgw'!Qs   )rL   r   re   BULK_SENDERS_PATH)senderr   s    @r>   _is_bulk_senderr      s'     llnGQ>O1PQQQrb   c                b    | syt        t              }| |v ry| j                  dd      d   }||v S )uj  Senders whose mail is always worth his eyes, whatever words it uses.

    This exists because keywords cannot do this job. The single most important email
    in six months of mail — the migration agent's 26/05 note that the Carpenter skills
    assessment must be finalised before he turns 45 — contains neither 'visa' nor
    'immigration'. Nothing in the ACT vocabulary caught it and nothing was ever going
    to. Only the sender identifies it.

    Matches an exact address, or an exact domain. Deliberately NOT suffix matching:
    'ato.gov.au' must not drag in 'news.ato.gov.au', which is a newsletter.
    FT@r   )re   CRITICAL_SENDERS_PATHrsplit)addressvaluesdomains      r>   _is_critical_senderr      s@     /0F&^^C#B'FVrb   c                v    | j                  dd      xs d}| j                  dd      xs d}| d|dt          S )z6Subject plus the opening of the body. See SCOPE_CHARS.subjectr   body
N)r8   SCOPE_CHARS)messager   r   s      r>   _scoper     sE    kk)R(.BG;;vr"(bDYbl{+,--rb   c                   | j                  dd      }t        |      r?| j                  dd      | d   ddgd d | j                  dd      || j                  d	      d
	S t               }| j                  dd      }| j                  dd      }t        |       }|j	                         }g }d}d}	t        |      }
|
D ]  }|j                  r!d}|j                  d|j                          0|j                  r!d}	|j                  d|j                          ]|j                  sjd}	|j                  d|j                           dD ]<  }|d   |   D ]/  \  }}|j                  |      sd}|j                  | d|        1 > t        |      d   j	                         }t        |      rd}|j                  d       |d   D ]-  \  }}|j                  |      sd}	|j                  d|        / | j                  d      rd}	|j                  d       d|v r+t        |      dk  r|rd|vrd|vrd}	|j                  d       |rdn|	rd nd}t        d! |
D        t        d" |
D        d             }| j                  dd      | d   |||r|j                   nd |r|j                  nd ||| j                  d	      d
	S )#Nfromr   accountsignup
message_idNOTEzbulk senderr   r   )	r   r   bucketreasonsnormalized_dateoriginal_date_textr   r   message_dater   FTzdate within 30 days: zambiguous date: zfuture date: rP   rT   z: r   zcritical senderrU   zreview term: attachmentszhas attachment?i  zno-replynoreplyz(short question from human-looking senderACTREVIEWc              3  :   K   | ]  }|j                   s|  y wrd   )rq   r   items     r>   r   zclassify.<locals>.<genexpr>X  s     7$4#6#67s   c              3  R   K   | ]  }|j                   s|j                  s| ! y wrd   )rn   rp   r   s     r>   r   zclassify.<locals>.<genexpr>Y  s     Jt4>>dJs   '')r8   r   ri   r   rL   r   rq   r   rm   rp   rr   searchr   r   r   nextrn   )r   
raw_senderr   r   r   scoper   r   rT   rU   dates
date_matchrZ   r[   r   sender_addressr   preferred_dates                     r>   classifyr     s   VR(Jz"{{9h7!,/%#"&{{9b1 #KK/

 
	
 ]Fkk)R(G;;vr"D7OEkkmGG
CF% E 	B
$$CNN2:3F3F2GHI!!FNN-j.A.A-BCD--FNN]:+>+>*?@A	B 0 3#E]51 	3MD'~~g&%4&12	33 z*1-335N>*()) 3g>>'"FNN]4&123 {{=!'(tIOn,^+ABU6xvF7%7JuJDQN
 ;;y(3l+8F>44D9Gn55TF+
 
rb   c                :    | j                  d      xs ddd xs dS )u  Findings are filed under the day the MESSAGE arrived, never the day we ran.

    The store used to have two writers with two different answers to this: the brief
    filed everything under today, the backfill filed it under the message date. Neither
    could see the other's rows, so running the backfill and then the brief wrote every
    finding twice — 8,758 rows for 4,351 messages.
    r   r   Nr$   undated)r8   )findings    r>   _finding_dayr   h  s$     KK'-2s3@y@rb   c            	        t               } t        j                         s| S t        t        j	                  d            D ]  }|j                  d      j                         D ]\  }|j                         s	 t        j                  |      }| j                  |j                  dd      |j                  dd      f       ^  | S # t        $ r Y nw xY w)zGEvery (account, message_id) already in the store, across all day files.z*.jsonlrA   rB   r   r   r   r   )rF   FINDINGS_DIRrE   sortedglobrG   rH   rI   jsonrX   rx   addr8   )knownr9   rM   records       r>   known_findingsr   s  s    "%%E |((34 WNNGN4??A 	WD::<D) IIvzz)X6

<QS8TUV	WW L  s   7C	CCc           	     h   | sg S t         j                  dd       t               }g }| D ]K  }t        |      }|j	                  dd      |d   f}||v r*|j                  |       |j                  |       M i }|D ],  }|j                  t        |      g       j                  |       . t        |j                               D ]_  \  }}t         | dz  }	|	j                  ddd	
      5 }
|D ]+  }|
j                  t        j                  |d      d	z          - 	 ddd       a |S # 1 sw Y   mxY w)zJThe ONE writer of the findings store. Idempotent, store-wide deduplicated.T)parentsexist_okr   r   r   z.jsonlarA   r   )rC   newlineF)ensure_asciiN)r   mkdirr   r   r8   r   r   
setdefaultr   r   itemsopenwriter   dumps)messagesexistingfreshr   r   identityby_dayr{   rowsr9   handlerows               r>   write_findingsr     sO   	td3H"$E 7#KK	84gl6KLxXW /1F E,w/4;;GDEFLLN+ I	TVn,YYsWdY; 	Iv ITZZ%@4GHI	I 	II
 L	I 	Is   *1D((D1	)r9   r	   returnr
   )r9   r	   r   zset[str])r9   r	   r   dict[str, Any])r[   rl   r   zre.Pattern[str])r   r   )ry   r   rz   r   r{   r   r   date | None)rm   rl   r   r   rp   ro   r   rk   )r   rl   r   zlist[DateMatch])r   rl   r   ro   )r   rl   r   ro   )r   r   r   rl   )r   r   r   r   )r   r   r   rl   )r   zset[tuple[str, str]])r   list[dict[str, Any]]r   r   )7__doc__
__future__r   r   r_   rW   dataclassesr   r   r   r   email.utilsr   pathlibr	   typingr
   notifyr   rg   r   r   r   r   	enumeraterL   r   r   abbreviationnumberr`   r   
IGNORECASEr   r   r   r3   rv   r?   rN   r]   rY   re   ri   rk   r|   r   r   r   r   r   r   r   r   r   )r
  names   00r>   <module>r     s   #  	  ! - - !    H_|+
x*@@ 8O&88 f}z)   "	

 	JJL&
* Qq1QQ"R %'"L& "F<	" 2::FGrzz
 MM rzzLMBJJ>?  )+% *
	H(-      8v	R*.OdA"s

s   +E