1from __future__ import annotations
2
3import datetime as dt
4import html
5import json
6import re
7import time
8import threading
9import urllib.error
10import urllib.request
11from dataclasses import dataclass
12from typing import Any
13
14SEC_API_ROOT = "https://data.sec.gov"
15SEC_ARCHIVE_ROOT = "https://www.sec.gov/Archives/edgar/data"
16DEFAULT_USER_AGENT = "Shields Enterprises Apify actor contact: outreach@brokerpay.io"
17SUPPORTED_FORMS = ("8-K", "10-Q", "10-K")
18
19HIGH_SIGNALS = {
20 "BANKRUPTCY": ("bankruptcy", "chapter 11"),
21 "DEFAULT_OR_ACCELERATION": ("event of default", "accelerated the obligations"),
22 "RESTATEMENT": ("should no longer be relied upon", "restated", "material misstatement"),
23 "EXECUTIVE_DISRUPTION": ("chief executive officer resigned", "ceo resigned", "interim ceo"),
24 "MAJOR_TRANSACTION": ("definitive agreement to acquire", "merger agreement"),
25 "IMPAIRMENT": ("impairment charge",),
26 "GUIDANCE_CHANGE": ("lowered full-year", "reduced guidance", "withdrawn guidance"),
27 "MATERIAL_CYBER_INCIDENT": ("cybersecurity incident", "material disruption", "unauthorized access"),
28 "GOING_CONCERN": ("going concern",),
29 "MATERIAL_WEAKNESS": ("material weakness",),
30}
31MEDIUM_SIGNALS = {
32 "GOVERNMENT_ACTION": ("department of justice", "lawsuit", "injunctive relief"),
33 "CUSTOMER_OR_CONTRACT_CHANGE": ("will not renew", "major customer", "supply contract"),
34 "PERFORMANCE_CHANGE": ("revenue decreased", "lower customer demand"),
35 "COVENANT_OR_LIQUIDITY": ("debt covenant", "credit facility", "headroom"),
36}
37
38
39@dataclass
40class ActorInputError(ValueError):
41 code: str
42 message: str
43 details: dict[str, Any]
44
45 def as_dict(self) -> dict[str, Any]:
46 return {"error": {"code": self.code, "message": self.message, "details": self.details}}
47
48
49def utc_now() -> str:
50 return dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z")
51
52
53def _plain_text(document: str) -> str:
54 document = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", document)
55 document = re.sub(r"(?s)<[^>]+>", " ", document)
56 return re.sub(r"\s+", " ", html.unescape(document)).strip()
57
58
59
60
61_request_lock = threading.Lock()
62_last_request_at: float | None = None
63_REQUEST_INTERVAL_SECONDS = 0.25
64
65
66def _source_request(url: str, user_agent: str):
67 global _last_request_at
68 if (not isinstance(user_agent, str) or len(user_agent) > 256
69 or any(ord(c) < 32 or ord(c) == 127 for c in user_agent)
70 or not re.search(r"[^\s@]+@[^\s@]+\.[^\s@]+", user_agent)):
71 raise ActorInputError("INVALID_INPUT", "user_agent must identify a contact email without control characters", {"field": "user_agent"})
72 request = urllib.request.Request(url, headers={"User-Agent": user_agent})
73 with _request_lock:
74 now = time.monotonic()
75 if _last_request_at is not None:
76 delay = _REQUEST_INTERVAL_SECONDS - (now - _last_request_at)
77 if delay > 0:
78 time.sleep(delay)
79 _last_request_at = time.monotonic()
80
81 return urllib.request.urlopen(request, timeout=30)
82
83
84def _request_json(url: str, user_agent: str) -> dict[str, Any]:
85 try:
86 with _source_request(url, user_agent) as response:
87 return json.loads(response.read())
88 except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
89 raise ActorInputError("SOURCE_FETCH_FAILED", "SEC source request failed", {"url": url, "cause": str(exc)}) from exc
90
91
92def _request_text(url: str, user_agent: str) -> str:
93 try:
94 with _source_request(url, user_agent) as response:
95 return response.read().decode("utf-8", errors="replace")
96 except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc:
97 raise ActorInputError("SOURCE_FETCH_FAILED", "SEC filing request failed", {"url": url, "cause": str(exc)}) from exc
98
99
100def _validated_recent_rows(payload: dict[str, Any], requested_cik: str) -> list[tuple[str, str, str, str]]:
101 if not isinstance(payload, dict):
102 raise ActorInputError("SOURCE_INVALID_RESPONSE", "SEC submissions response was not an object", {})
103 payload_cik = str(payload.get("cik", "")).strip()
104 if not payload_cik.isdigit() or int(payload_cik) != int(requested_cik):
105 raise ActorInputError(
106 "SOURCE_IDENTITY_MISMATCH",
107 "SEC submissions response CIK did not match the requested CIK",
108 {"requested_cik": f"{int(requested_cik):010d}", "returned_cik": payload_cik or None},
109 )
110 recent = payload.get("filings", {}).get("recent", {})
111 if not isinstance(recent, dict):
112 raise ActorInputError("SOURCE_INVALID_RESPONSE", "SEC recent filings response was not an object", {})
113 keys = ("form", "accessionNumber", "primaryDocument", "filingDate")
114 columns = [recent.get(key) for key in keys]
115 if any(not isinstance(column, list) for column in columns):
116 raise ActorInputError("SOURCE_INVALID_RESPONSE", "SEC recent filing columns were missing or invalid", {"required_columns": list(keys)})
117 lengths = {len(column) for column in columns}
118 if len(lengths) != 1 or not lengths or next(iter(lengths)) == 0:
119 raise ActorInputError("SOURCE_INVALID_RESPONSE", "SEC recent filing columns were empty or misaligned", {"column_lengths": dict(zip(keys, map(len, columns)))})
120 return list(zip(*columns))
121
122
123def _is_negated_match(sentence: str, start: int, end: int) -> bool:
124 before = sentence[max(0, start - 120):start]
125 after = sentence[end:end + 120]
126
127
128
129
130
131 contrastive = r"[,;]?\s+(?:but|however|yet)\b"
132 last_cut = None
133 for m in re.finditer(contrastive, before):
134 last_cut = m.end()
135 if last_cut is not None:
136 before = before[last_cut:]
137 first_cut_after = None
138 am = re.search(contrastive, after)
139 if am:
140 first_cut_after = am.start()
141 after_for_negator = after[:first_cut_after] if first_cut_after is not None else after
142
143
144 before = re.sub(
145 r"\b(?:no\s+doubt\s+that|no\s+uncertainty\s+remains"
146 r"|not\s+only|no\s+reason\s+to\s+doubt(?:\s+that)?)\b",
147 "", before,
148 )
149
150
151 before = re.sub(
152 r"\b(?:did\s+not|does\s+not|do\s+not|cannot|could\s+not)"
153 r"\s+(?:deny|refute|dispute|disprove)\b",
154 " AFFIRMED ", before,
155 )
156 before = re.sub(r"\bnot\s+without\b", " AFFIRMED_WITHOUT ", before)
157
158 negator = r"(?:no|not|never|without|neither|did\s+not|does\s+not|has\s+not|have\s+not|had\s+not)"
159
160
161 unresolved_pat = (
162 r"\b(?:not|never|no)\b\s+(?:\w+\s+){0,3}"
163 r"\b(?:alleviated|remediated|resolved|avoided)\b"
164 )
165 has_unresolved = bool(re.search(unresolved_pat, after[:120]))
166
167
168 has_resolver = (
169 bool(re.search(r"\b(?:alleviated|remediated|resolved|avoided)\b", after[:80]))
170 and not has_unresolved
171 )
172
173
174 backward = bool(re.search(rf"\b{negator}\b(?:\W+\w+){{0,8}}\W*$", before))
175
176
177 forward = (
178 bool(re.match(rf"^(?:\W+\w+){{0,8}}\W+{negator}\b", after_for_negator))
179 and not has_unresolved
180 )
181
182 return backward or forward or has_resolver
183
184
185def _scan_signals(text: str, signal_set: dict[str, tuple[str, ...]]) -> tuple[list[str], list[str], list[str]]:
186 active_codes: list[str] = []
187 negated_codes: list[str] = []
188 matched_phrases: list[str] = []
189 for code, phrases in signal_set.items():
190 active = False
191 negated = False
192 for phrase in phrases:
193 for match in re.finditer(re.escape(phrase), text):
194 sentence_start = max(text.rfind(".", 0, match.start()), text.rfind(";", 0, match.start()), text.rfind("\n", 0, match.start())) + 1
195 sentence_ends = [position for position in (text.find(".", match.end()), text.find(";", match.end()), text.find("\n", match.end())) if position >= 0]
196 sentence_end = min(sentence_ends) if sentence_ends else len(text)
197 sentence = text[sentence_start:sentence_end]
198 local_start = match.start() - sentence_start
199 local_end = match.end() - sentence_start
200 matched_phrases.append(phrase)
201 if _is_negated_match(sentence, local_start, local_end):
202 negated = True
203 else:
204 active = True
205 if active:
206 active_codes.append(code)
207 elif negated:
208 negated_codes.append(code)
209 return active_codes, negated_codes, matched_phrases
210
211
212def fetch_latest_filing(actor_input: dict[str, Any]) -> dict[str, Any]:
213 if not isinstance(actor_input, dict):
214 raise ActorInputError("INVALID_INPUT", "input must be an object", {})
215 allowed = {"cik", "forms", "form", "accession", "user_agent", "exposure_keywords"}
216 unknown = sorted(set(actor_input) - allowed)
217 if unknown:
218 raise ActorInputError("INVALID_INPUT", "Unknown input fields", {"fields": unknown})
219 cik = str(actor_input.get("cik", "")).strip()
220 if not re.fullmatch(r"[0-9]{1,10}", cik) or int(cik) == 0:
221 raise ActorInputError("INVALID_INPUT", "cik must be 1 to 10 digits and positive", {"field": "cik"})
222 if "form" in actor_input and "forms" in actor_input:
223 raise ActorInputError("INVALID_INPUT", "Use form or forms, not both", {})
224 forms = [actor_input["form"]] if "form" in actor_input else actor_input.get("forms", ["8-K", "10-Q", "10-K"])
225 if not isinstance(forms, list) or not forms or any(not isinstance(f, str) or f not in ("8-K", "10-Q", "10-K") for f in forms):
226 raise ActorInputError("INVALID_INPUT", "forms must be a nonempty list of 8-K, 10-Q or 10-K", {})
227 requested_accession = actor_input.get("accession")
228 if "accession" in actor_input and (not isinstance(requested_accession, str) or not re.fullmatch(r"[0-9]{10}-[0-9]{2}-[0-9]{6}", requested_accession)):
229 raise ActorInputError("INVALID_INPUT", "Invalid accession", {})
230 normalized_cik = f"{int(cik):010d}"
231 user_agent = str(actor_input.get("user_agent") or DEFAULT_USER_AGENT)
232 submissions_url = f"{SEC_API_ROOT}/submissions/CIK{normalized_cik}.json"
233 payload = _request_json(submissions_url, user_agent)
234 rows = _validated_recent_rows(payload, cik)
235 selected = next((row for row in rows if row[0] in forms and (requested_accession is None or row[1] == requested_accession)), None)
236 if not selected:
237 raise ActorInputError("NO_MATCHING_FILING", "No recent filing matched requested forms", {"cik": cik, "forms": forms, "source_url": submissions_url})
238 form, accession, primary_document, filed_at = selected
239 if (not isinstance(form, str) or form not in forms
240 or not isinstance(accession, str) or not re.fullmatch(r"[0-9]{10}-[0-9]{2}-[0-9]{6}", accession)
241 or not isinstance(primary_document, str) or not re.fullmatch(r"[A-Za-z0-9._-]+", primary_document)
242 or not isinstance(filed_at, str) or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", filed_at)):
243 raise ActorInputError("SOURCE_INVALID_RESPONSE", "Selected SEC filing identity fields were invalid", {"requested_cik": normalized_cik})
244 if requested_accession is not None and accession != requested_accession:
245 raise ActorInputError("SOURCE_IDENTITY_MISMATCH", "Selected SEC accession did not match the requested accession", {"requested_accession": requested_accession, "selected_accession": accession})
246 filing_url = f"{SEC_ARCHIVE_ROOT}/{int(cik)}/{accession.replace('-', '')}/{primary_document}"
247 text = _plain_text(_request_text(filing_url, user_agent))
248 if not text:
249 raise ActorInputError("SOURCE_PARSE_FAILED", "SEC filing contained no readable text", {"url": filing_url})
250 return {
251 "id": accession,
252 "form": form,
253 "company": payload.get("name") or f"CIK {cik}",
254 "filed_at": filed_at,
255 "text": text,
256 "source_url": filing_url,
257 "accession_number": accession,
258 "cik": normalized_cik,
259 "exposure_keywords": actor_input.get("exposure_keywords") or [],
260 }
261
262
263def triage_filing(filing: dict[str, Any], now: str | None = None) -> dict[str, Any]:
264 missing = [field for field in ("form", "text") if not str(filing.get(field, "")).strip()]
265 if missing:
266 raise ActorInputError("INVALID_INPUT", "Required filing fields are missing", {"missing_fields": missing})
267 text = re.sub(r"\s+", " ", str(filing["text"])).strip()
268 lowered = text.lower()
269 high_codes, negated_high_codes, high_phrases = _scan_signals(lowered, HIGH_SIGNALS)
270 medium_codes, negated_medium_codes, medium_phrases = _scan_signals(lowered, MEDIUM_SIGNALS)
271 negated_codes = negated_high_codes + negated_medium_codes
272 exposure_hits = sorted({str(keyword) for keyword in filing.get("exposure_keywords", []) if str(keyword).strip() and str(keyword).lower() in lowered})
273 if high_codes:
274 decision, level, confidence = "review_now", "high", 0.9
275 reason_codes = high_codes + (["EXPOSURE_KEYWORD_MATCH"] if exposure_hits else [])
276 review_next = "A qualified analyst should review the cited filing now and update the affected forecast, covenant, or diligence work queue."
277 elif medium_codes or exposure_hits:
278 decision, level, confidence = "queue_review", "medium", 0.76
279 reason_codes = medium_codes + (["NEGATED_SIGNAL_PRESENT"] if negated_codes else []) + (["EXPOSURE_KEYWORD_MATCH"] if exposure_hits else [])
280 review_next = "Queue a human review during the current analysis cycle; confirm magnitude and business exposure from the full filing."
281 elif negated_codes:
282 decision, level, confidence = "queue_review", "undetermined", 0.35
283 reason_codes = ["ABSTAIN_NEGATED_MATERIAL_SIGNAL"] + [f"NEGATED_{code}" for code in negated_codes]
284 review_next = "The bounded rules found a material phrase in negated or resolved context and abstained; a human should confirm the clause before clearing it."
285 else:
286 decision, level, confidence = "monitor", "low", 0.62
287 reason_codes = ["NO_BOUNDED_MATERIAL_SIGNAL"]
288 review_next = "Retain the filing in the watch record; no immediate materiality escalation was identified by the bounded rules."
289 source_url = str(filing.get("source_url") or f"fixture://{filing.get('id', 'unknown')}")
290 matched_phrases = high_phrases + medium_phrases
291 anchor = matched_phrases[0] if matched_phrases else (exposure_hits[0].lower() if exposure_hits else "")
292 position = lowered.find(anchor) if anchor else 0
293 start = max(0, position - 100)
294 quote = text[start : start + 360]
295 assessed_at = now or utc_now()
296 accession = str(filing.get("accession_number") or filing.get("id") or "")
297 cik = str(filing.get("cik") or "")
298 filing_identity = {
299 "cik": cik if re.fullmatch(r"[0-9]{10}", cik) else None,
300 "form": str(filing["form"]),
301 "accession_number": accession if re.fullmatch(r"[0-9]{10}-[0-9]{2}-[0-9]{6}", accession) else None,
302 }
303 return {
304 "case_id": str(filing.get("id") or filing.get("accession_number") or "unassigned"),
305 "buyer_decision": "Should an FP&A or research analyst review this filing now?",
306 "filing_identity": filing_identity,
307 "decision": decision,
308 "materiality_level": level,
309 "reason_codes": reason_codes,
310 "summary": f"{filing.get('company', 'Issuer')} {filing['form']} classified {level} for bounded analyst triage.",
311 "evidence": [{"quote": quote, "source_url": source_url, "matched_exposure_keywords": exposure_hits}],
312 "source": {"url": source_url, "publisher": "U.S. Securities and Exchange Commission", "filed_at": filing.get("filed_at"), "accessed_at": assessed_at, "filing_identity": filing_identity},
313 "assessed_at": assessed_at,
314 "confidence": confidence,
315 "review_next": review_next,
316 "limitations": [
317 "Deterministic keyword triage is not investment, legal, accounting, or disclosure advice.",
318 "A low result does not prove immateriality; tables, exhibits, complex negation, and issuer-specific context may require human review.",
319 "Negated or resolved material phrases trigger a bounded abstention unless another unnegated signal is present.",
320 "The Actor reads one latest matching filing from the official SEC source and never trades, alerts third parties, or changes records.",
321 ],
322 }