Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/political_event_tracking_research/official_event_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ class OfficialRecord:
direction: str
source_url: str
summary: str
entity_match_type: str = "unverified"
match_evidence: str = ""
relationship_type: str = "unverified"


def slug(value: str) -> str:
Expand Down Expand Up @@ -76,6 +79,9 @@ def load_official_records(path: str | Path) -> list[OfficialRecord]:
direction=row.get("direction", ""),
source_url=row["source_url"],
summary=row.get("summary", ""),
entity_match_type=row.get("entity_match_type", "unverified") or "unverified",
match_evidence=row.get("match_evidence", ""),
relationship_type=row.get("relationship_type", "unverified") or "unverified",
)
)
return records
Expand All @@ -87,6 +93,11 @@ def validate_record(record: OfficialRecord) -> None:
raise ValueError("record_id is required")
if not record.symbol.strip():
raise ValueError(f"{record.record_id}: symbol is required")
allowed_match_types = {"issuer", "direct_beneficiary", "industry_context", "unverified"}
if record.entity_match_type not in allowed_match_types:
raise ValueError(f"{record.record_id}: unsupported entity_match_type {record.entity_match_type!r}")
if record.relationship_type not in allowed_match_types:
raise ValueError(f"{record.record_id}: unsupported relationship_type {record.relationship_type!r}")
if record.event_type not in ALLOWED_EVENT_TYPES:
raise ValueError(f"{record.record_id}: unsupported event_type {record.event_type!r}")
if record.source_type in GOVERNMENT_SOURCE_TYPES:
Expand Down Expand Up @@ -125,6 +136,9 @@ def normalize_records(records: list[OfficialRecord]) -> list[dict[str, str]]:
"confidence": confidence_for_source(record),
"source_url": record.source_url,
"notes": record.summary,
"entity_match_type": record.entity_match_type,
"match_evidence": record.match_evidence,
"relationship_type": record.relationship_type,
}
)
rows.sort(key=lambda row: (row["event_date"], row["symbol"], row["event_id"]))
Expand All @@ -136,7 +150,19 @@ def import_official_events(input_path: str | Path, output_path: str | Path) -> l
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
Expand Down
122 changes: 107 additions & 15 deletions src/political_event_tracking_research/source_mention_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +50,7 @@ class RawSourceItem:
class MentionAlias:
symbol: str
aliases: tuple[str, ...]
name: str = ""


def load_raw_items(path: str | Path) -> list[RawSourceItem]:
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize configured generic alias variants

This exact membership check misses generic aliases already present in config/core_us_equity_aliases.csv, for example energy related infrastructure alongside the denylisted hyphenated form on config lines 9, 24, 40, and 41. A government-policy item containing the unhyphenated phrase therefore falls through to issuer evidence instead of industry_context; normalize equivalent hyphen/space variants or include the configured variants in the generic classification.

Useful? React with 👍 / 👎.



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+"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove reversed benefits-from cue

When a sentence says another party benefits from a company, this cue treats the company after benefits from as the direct beneficiary. For example, a government item like Domestic suppliers benefit from Intel investment marks INTC as direct_beneficiary, even though the beneficiaries are the suppliers and Intel is just source/context, which overstates company-level support in the new relationship fields.

Useful? React with 👍 / 👎.

rf"(?:the\s+)?{re.escape(normalized_alias)}",
Comment on lines +134 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Anchor direct-beneficiary aliases at word boundaries

Because this direct-beneficiary regex inserts the escaped alias without the same trailing boundary used by alias_pattern, an item that mentions a company elsewhere can be upgraded by a prefix match in an unrelated beneficiary, e.g. Ford earnings were discussed. Funding for Fordham University... returns direct_beneficiary for Ford. Reusing the alias boundary here avoids turning longer names that merely start with a ticker/company alias into direct company evidence.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent generic phrases from becoming direct beneficiaries

When a denylisted alias appears after a direct-beneficiary cue, this branch runs before the generic-alias check below, so text such as funding for cybersecurity is emitted as direct_beneficiary for PANW/CRWD rather than industry_context. That turns sector-level funding language into company-level evidence and can corrupt the relationship fields the commit is adding; the direct pattern should not promote aliases that are only generic context terms.

Useful? React with 👍 / 👎.

elif source_type == "issuer_release" and is_canonical_name:
relationship = "issuer"
Comment on lines +141 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not trust third-party names in issuer releases

When any issuer_release item mentions another covered company by its canonical name, this branch promotes that other company to issuer evidence even though source_items.csv has no field identifying which issuer owns the release. For example, an NVIDIA issuer release saying it partnered with Palo Alto Networks would emit PANW as issuer evidence instead of a third-party mention; only promote issuer evidence when the release can be tied to the same symbol.

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()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prefer explicit aliases over generic context

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 industry_context outranks unverified; for example, PANW aliases include both Palo Alto Networks and cybersecurity, so a government item like Palo Alto Networks cybersecurity guidance is emitted as industry_context with match_evidence=cybersecurity. That loses the explicit company-name evidence in the new relationship fields, so generic aliases should only win when no non-generic company/ticker alias matched.

Useful? React with 👍 / 👎.

)
return relationship, evidence


def infer_event_type(item: RawSourceItem) -> str:
source_type = item.source_type
text = item.text.lower()
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate repeated symbols after evidence matching

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 match_symbols path, nothing deduplicates by symbol before writing. That produces duplicate event_id rows for one source item, which downstream tracker/event-study code counts as separate events and can inflate scores or returns unless the strongest evidence is collapsed per (item_id, symbol).

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
Expand Down
2 changes: 2 additions & 0 deletions tests/test_official_event_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ def test_import_official_events_normalizes_to_event_schema(tmp_path: Path) -> No
assert rows[-2]["event_id"] == "official-issuer-release-demo-issuer-evt3"
assert rows[-1]["event_id"] == "official-financial-media-demo-media-evt5"
assert rows[-1]["confidence"] == "low"
assert rows[0]["entity_match_type"] == "unverified"
assert rows[0]["relationship_type"] == "unverified"


def test_government_records_reject_non_gov_urls() -> None:
Expand Down
Loading
Loading