-
Notifications
You must be signed in to change notification settings - Fork 0
feat: retain entity relationship evidence in extracted events #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,32 @@ | |
| from .official_event_import import OfficialRecord, normalize_records | ||
|
|
||
|
|
||
| GENERIC_ALIAS_DENYLIST = frozenset( | ||
| { | ||
| "advanced nuclear", | ||
| "bitcoin treasury", | ||
| "chips act", | ||
| "crypto assets", | ||
| "cybersecurity", | ||
| "digital assets", | ||
| "energy-related infrastructure", | ||
| "foundry", | ||
| "nuclear energy", | ||
| "nuclear reactor", | ||
| "semiconductor manufacturing", | ||
| "strategy", | ||
| "tokenization", | ||
| } | ||
| ) | ||
|
|
||
| RELATIONSHIP_PRIORITY = { | ||
| "unverified": 0, | ||
| "industry_context": 1, | ||
| "issuer": 2, | ||
| "direct_beneficiary": 3, | ||
| } | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class RawSourceItem: | ||
| item_id: str | ||
|
|
@@ -24,6 +50,7 @@ class RawSourceItem: | |
| class MentionAlias: | ||
| symbol: str | ||
| aliases: tuple[str, ...] | ||
| name: str = "" | ||
|
|
||
|
|
||
| def load_raw_items(path: str | Path) -> list[RawSourceItem]: | ||
|
|
@@ -51,10 +78,11 @@ def load_aliases(path: str | Path) -> list[MentionAlias]: | |
| records: list[MentionAlias] = [] | ||
| for row in read_csv_rows(path): | ||
| symbol = row["symbol"].upper() | ||
| name = row.get("name", "").strip() | ||
| aliases = split_aliases(row.get("aliases", "")) | ||
| if symbol not in aliases: | ||
| aliases = (symbol, *aliases) | ||
| records.append(MentionAlias(symbol=symbol, aliases=aliases)) | ||
| records.append(MentionAlias(symbol=symbol, aliases=aliases, name=name)) | ||
| return records | ||
|
|
||
|
|
||
|
|
@@ -88,6 +116,44 @@ def match_symbols(text: str, aliases: list[MentionAlias]) -> list[str]: | |
| return sorted(dict.fromkeys(matches)) | ||
|
|
||
|
|
||
| def _is_generic_alias(alias_record: MentionAlias, alias: str) -> bool: | ||
| normalized = normalize_match_text(alias).strip() | ||
| if normalized.upper() == alias_record.symbol.upper(): | ||
| return False | ||
| return normalized.casefold() in GENERIC_ALIAS_DENYLIST | ||
|
|
||
|
|
||
| def match_evidence(text: str, alias_record: MentionAlias, source_type: str = "") -> tuple[str, str] | None: | ||
| normalized_text = normalize_match_text(text) | ||
| matches: list[tuple[str, str, bool]] = [] | ||
| for alias in alias_record.aliases: | ||
| normalized_alias = normalize_match_text(alias) | ||
| if not alias_pattern(alias).search(normalized_text): | ||
| continue | ||
| direct_pattern = re.compile( | ||
| rf"(?:awarded to|contract with|funding for|benefit(?:s|ed)? from)\s+" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a sentence says another party benefits from a company, this cue treats the company after Useful? React with 👍 / 👎. |
||
| rf"(?:the\s+)?{re.escape(normalized_alias)}", | ||
|
Comment on lines
+134
to
+135
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because this direct-beneficiary regex inserts the escaped alias without the same trailing boundary used by Useful? React with 👍 / 👎. |
||
| re.IGNORECASE, | ||
| ) | ||
| is_canonical_name = bool(alias_record.name) and alias.casefold() == alias_record.name.casefold() | ||
| if direct_pattern.search(normalized_text): | ||
| relationship = "direct_beneficiary" | ||
|
Comment on lines
+139
to
+140
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a denylisted alias appears after a direct-beneficiary cue, this branch runs before the generic-alias check below, so text such as Useful? React with 👍 / 👎. |
||
| elif source_type == "issuer_release" and is_canonical_name: | ||
| relationship = "issuer" | ||
|
Comment on lines
+141
to
+142
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When any Useful? React with 👍 / 👎. |
||
| elif _is_generic_alias(alias_record, alias): | ||
| relationship = "industry_context" | ||
| else: | ||
| relationship = "unverified" | ||
| matches.append((relationship, normalized_alias, is_canonical_name)) | ||
| if not matches: | ||
| return None | ||
| relationship, evidence, _ = max( | ||
| matches, | ||
| key=lambda match: (RELATIONSHIP_PRIORITY[match[0]], match[2], len(match[1]), match[1].casefold()), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When one item contains both a real company alias and a denylisted context alias for the same symbol, this ordering causes the generic match to win because Useful? React with 👍 / 👎. |
||
| ) | ||
| return relationship, evidence | ||
|
|
||
|
|
||
| def infer_event_type(item: RawSourceItem) -> str: | ||
| source_type = item.source_type | ||
| text = item.text.lower() | ||
|
|
@@ -133,24 +199,50 @@ def extract_source_records(raw_items_path: str | Path, aliases_path: str | Path, | |
| aliases = load_aliases(aliases_path) | ||
| records: list[OfficialRecord] = [] | ||
| for item in raw_items: | ||
| symbols = match_symbols(item.text, aliases) | ||
| for symbol in symbols: | ||
| records.append( | ||
| OfficialRecord( | ||
| record_id=f"{item.item_id}-{symbol.lower()}", | ||
| record_date=item_date(item), | ||
| symbol=symbol, | ||
| source_type=item.source_type, | ||
| event_type=infer_event_type(item), | ||
| direction=infer_direction(item.text), | ||
| source_url=item.source_url, | ||
| summary=f"{item.author}: {item.text}".strip(": "), | ||
| ) | ||
| best_by_symbol: dict[str, tuple[OfficialRecord, tuple[int, int, str]]] = {} | ||
| for alias_record in aliases: | ||
| evidence = match_evidence(item.text, alias_record, item.source_type) | ||
| if evidence is None: | ||
| continue | ||
| entity_match_type, matched_text = evidence | ||
|
Comment on lines
+203
to
+207
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an operator-provided aliases CSV has more than one row for the same symbol, this per-alias-record loop appends one event for each row; unlike the previous Useful? React with 👍 / 👎. |
||
| record = OfficialRecord( | ||
| record_id=f"{item.item_id}-{alias_record.symbol.lower()}", | ||
| record_date=item_date(item), | ||
| symbol=alias_record.symbol, | ||
| source_type=item.source_type, | ||
| event_type=infer_event_type(item), | ||
| direction=infer_direction(item.text), | ||
| source_url=item.source_url, | ||
| summary=f"{item.author}: {item.text}".strip(": "), | ||
| entity_match_type=entity_match_type, | ||
| match_evidence=matched_text, | ||
| relationship_type=entity_match_type, | ||
| ) | ||
| evidence_key = ( | ||
| RELATIONSHIP_PRIORITY[entity_match_type], | ||
| len(matched_text), | ||
| matched_text.casefold(), | ||
| ) | ||
| current = best_by_symbol.get(alias_record.symbol) | ||
| if current is None or evidence_key > current[1]: | ||
| best_by_symbol[alias_record.symbol] = (record, evidence_key) | ||
| records.extend(record for record, _ in best_by_symbol.values()) | ||
| rows = normalize_records(records) | ||
| write_csv_rows( | ||
| output_path, | ||
| ["event_id", "event_date", "symbol", "event_type", "direction", "confidence", "source_url", "notes"], | ||
| [ | ||
| "event_id", | ||
| "event_date", | ||
| "symbol", | ||
| "event_type", | ||
| "direction", | ||
| "confidence", | ||
| "source_url", | ||
| "notes", | ||
| "entity_match_type", | ||
| "match_evidence", | ||
| "relationship_type", | ||
| ], | ||
| rows, | ||
| ) | ||
| return rows | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This exact membership check misses generic aliases already present in
config/core_us_equity_aliases.csv, for exampleenergy related infrastructurealongside the denylisted hyphenated form on config lines 9, 24, 40, and 41. A government-policy item containing the unhyphenated phrase therefore falls through toissuerevidence instead ofindustry_context; normalize equivalent hyphen/space variants or include the configured variants in the generic classification.Useful? React with 👍 / 👎.