Skip to content

Commit 1d21f8b

Browse files
Pigbibicodex
andcommitted
feat: retain entity relationship evidence in extracted events
Co-Authored-By: Codex <noreply@openai.com>
1 parent 928d4f5 commit 1d21f8b

4 files changed

Lines changed: 220 additions & 7 deletions

File tree

src/political_event_tracking_research/official_event_import.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ class OfficialRecord:
4545
direction: str
4646
source_url: str
4747
summary: str
48+
entity_match_type: str = "unverified"
49+
match_evidence: str = ""
50+
relationship_type: str = "unverified"
4851

4952

5053
def slug(value: str) -> str:
@@ -76,6 +79,9 @@ def load_official_records(path: str | Path) -> list[OfficialRecord]:
7679
direction=row.get("direction", ""),
7780
source_url=row["source_url"],
7881
summary=row.get("summary", ""),
82+
entity_match_type=row.get("entity_match_type", "unverified") or "unverified",
83+
match_evidence=row.get("match_evidence", ""),
84+
relationship_type=row.get("relationship_type", "unverified") or "unverified",
7985
)
8086
)
8187
return records
@@ -87,6 +93,11 @@ def validate_record(record: OfficialRecord) -> None:
8793
raise ValueError("record_id is required")
8894
if not record.symbol.strip():
8995
raise ValueError(f"{record.record_id}: symbol is required")
96+
allowed_match_types = {"issuer", "direct_beneficiary", "industry_context", "unverified"}
97+
if record.entity_match_type not in allowed_match_types:
98+
raise ValueError(f"{record.record_id}: unsupported entity_match_type {record.entity_match_type!r}")
99+
if record.relationship_type not in allowed_match_types:
100+
raise ValueError(f"{record.record_id}: unsupported relationship_type {record.relationship_type!r}")
90101
if record.event_type not in ALLOWED_EVENT_TYPES:
91102
raise ValueError(f"{record.record_id}: unsupported event_type {record.event_type!r}")
92103
if record.source_type in GOVERNMENT_SOURCE_TYPES:
@@ -125,6 +136,9 @@ def normalize_records(records: list[OfficialRecord]) -> list[dict[str, str]]:
125136
"confidence": confidence_for_source(record),
126137
"source_url": record.source_url,
127138
"notes": record.summary,
139+
"entity_match_type": record.entity_match_type,
140+
"match_evidence": record.match_evidence,
141+
"relationship_type": record.relationship_type,
128142
}
129143
)
130144
rows.sort(key=lambda row: (row["event_date"], row["symbol"], row["event_id"]))
@@ -136,7 +150,19 @@ def import_official_events(input_path: str | Path, output_path: str | Path) -> l
136150
rows = normalize_records(records)
137151
write_csv_rows(
138152
output_path,
139-
["event_id", "event_date", "symbol", "event_type", "direction", "confidence", "source_url", "notes"],
153+
[
154+
"event_id",
155+
"event_date",
156+
"symbol",
157+
"event_type",
158+
"direction",
159+
"confidence",
160+
"source_url",
161+
"notes",
162+
"entity_match_type",
163+
"match_evidence",
164+
"relationship_type",
165+
],
140166
rows,
141167
)
142168
return rows

src/political_event_tracking_research/source_mention_extract.py

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,32 @@
1010
from .official_event_import import OfficialRecord, normalize_records
1111

1212

13+
GENERIC_ALIAS_DENYLIST = frozenset(
14+
{
15+
"advanced nuclear",
16+
"bitcoin treasury",
17+
"chips act",
18+
"crypto assets",
19+
"cybersecurity",
20+
"digital assets",
21+
"energy-related infrastructure",
22+
"foundry",
23+
"nuclear energy",
24+
"nuclear reactor",
25+
"semiconductor manufacturing",
26+
"strategy",
27+
"tokenization",
28+
}
29+
)
30+
31+
RELATIONSHIP_PRIORITY = {
32+
"unverified": 0,
33+
"industry_context": 1,
34+
"issuer": 2,
35+
"direct_beneficiary": 3,
36+
}
37+
38+
1339
@dataclass(frozen=True)
1440
class RawSourceItem:
1541
item_id: str
@@ -24,6 +50,7 @@ class RawSourceItem:
2450
class MentionAlias:
2551
symbol: str
2652
aliases: tuple[str, ...]
53+
name: str = ""
2754

2855

2956
def load_raw_items(path: str | Path) -> list[RawSourceItem]:
@@ -51,10 +78,13 @@ def load_aliases(path: str | Path) -> list[MentionAlias]:
5178
records: list[MentionAlias] = []
5279
for row in read_csv_rows(path):
5380
symbol = row["symbol"].upper()
81+
name = row.get("name", "").strip()
5482
aliases = split_aliases(row.get("aliases", ""))
83+
if name and name.casefold() not in {alias.casefold() for alias in aliases}:
84+
aliases = (name, *aliases)
5585
if symbol not in aliases:
5686
aliases = (symbol, *aliases)
57-
records.append(MentionAlias(symbol=symbol, aliases=aliases))
87+
records.append(MentionAlias(symbol=symbol, aliases=aliases, name=name))
5888
return records
5989

6090

@@ -88,6 +118,40 @@ def match_symbols(text: str, aliases: list[MentionAlias]) -> list[str]:
88118
return sorted(dict.fromkeys(matches))
89119

90120

121+
def _is_generic_alias(alias_record: MentionAlias, alias: str) -> bool:
122+
normalized = normalize_match_text(alias).strip()
123+
if normalized.upper() == alias_record.symbol.upper():
124+
return False
125+
return normalized.casefold() in GENERIC_ALIAS_DENYLIST
126+
127+
128+
def match_evidence(text: str, alias_record: MentionAlias, source_type: str = "") -> tuple[str, str] | None:
129+
normalized_text = normalize_match_text(text)
130+
matches: list[tuple[str, str]] = []
131+
for alias in alias_record.aliases:
132+
normalized_alias = normalize_match_text(alias)
133+
if not alias_pattern(alias).search(normalized_text):
134+
continue
135+
direct_pattern = re.compile(
136+
rf"(?:awarded to|contract with|funding for|benefit(?:s|ed)? from)\s+"
137+
rf"(?:the\s+)?{re.escape(normalized_alias)}",
138+
re.IGNORECASE,
139+
)
140+
is_canonical_name = bool(alias_record.name) and alias.casefold() == alias_record.name.casefold()
141+
if direct_pattern.search(normalized_text):
142+
relationship = "direct_beneficiary"
143+
elif source_type == "issuer_release" and is_canonical_name:
144+
relationship = "issuer"
145+
elif _is_generic_alias(alias_record, alias):
146+
relationship = "industry_context"
147+
else:
148+
relationship = "issuer"
149+
matches.append((relationship, normalized_alias))
150+
if not matches:
151+
return None
152+
return max(matches, key=lambda match: (RELATIONSHIP_PRIORITY[match[0]], match[1].casefold()))
153+
154+
91155
def infer_event_type(item: RawSourceItem) -> str:
92156
source_type = item.source_type
93157
text = item.text.lower()
@@ -133,24 +197,42 @@ def extract_source_records(raw_items_path: str | Path, aliases_path: str | Path,
133197
aliases = load_aliases(aliases_path)
134198
records: list[OfficialRecord] = []
135199
for item in raw_items:
136-
symbols = match_symbols(item.text, aliases)
137-
for symbol in symbols:
200+
for alias_record in aliases:
201+
evidence = match_evidence(item.text, alias_record, item.source_type)
202+
if evidence is None:
203+
continue
204+
entity_match_type, matched_text = evidence
138205
records.append(
139206
OfficialRecord(
140-
record_id=f"{item.item_id}-{symbol.lower()}",
207+
record_id=f"{item.item_id}-{alias_record.symbol.lower()}",
141208
record_date=item_date(item),
142-
symbol=symbol,
209+
symbol=alias_record.symbol,
143210
source_type=item.source_type,
144211
event_type=infer_event_type(item),
145212
direction=infer_direction(item.text),
146213
source_url=item.source_url,
147214
summary=f"{item.author}: {item.text}".strip(": "),
215+
entity_match_type=entity_match_type,
216+
match_evidence=matched_text,
217+
relationship_type=entity_match_type,
148218
)
149219
)
150220
rows = normalize_records(records)
151221
write_csv_rows(
152222
output_path,
153-
["event_id", "event_date", "symbol", "event_type", "direction", "confidence", "source_url", "notes"],
223+
[
224+
"event_id",
225+
"event_date",
226+
"symbol",
227+
"event_type",
228+
"direction",
229+
"confidence",
230+
"source_url",
231+
"notes",
232+
"entity_match_type",
233+
"match_evidence",
234+
"relationship_type",
235+
],
154236
rows,
155237
)
156238
return rows

tests/test_official_event_import.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ def test_import_official_events_normalizes_to_event_schema(tmp_path: Path) -> No
2828
assert rows[-2]["event_id"] == "official-issuer-release-demo-issuer-evt3"
2929
assert rows[-1]["event_id"] == "official-financial-media-demo-media-evt5"
3030
assert rows[-1]["confidence"] == "low"
31+
assert rows[0]["entity_match_type"] == "unverified"
32+
assert rows[0]["relationship_type"] == "unverified"
3133

3234

3335
def test_government_records_reject_non_gov_urls() -> None:

tests/test_source_mention_extract.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,106 @@ def test_extract_source_records_outputs_confidence_by_source_type(tmp_path: Path
5151
assert by_symbol["EVT3"]["confidence"] == "low"
5252
assert by_symbol["EVT4"]["event_type"] == "procurement"
5353
assert output.exists()
54+
55+
56+
def test_entity_evidence_prefers_strongest_alias_match(tmp_path: Path) -> None:
57+
raw_items = tmp_path / "source_items.csv"
58+
raw_items.write_text(
59+
"item_id,published_at,source_type,source_url,author,text\n"
60+
"mixed,2026-04-01T00:00:00Z,government_procurement,https://www.govinfo.gov/example,Agency,"
61+
'Funding for Current Company supports cybersecurity.\n',
62+
encoding="utf-8",
63+
)
64+
aliases = tmp_path / "aliases.csv"
65+
aliases.write_text(
66+
"symbol,name,aliases\nTEST,Current Company,cybersecurity|Current Company\n",
67+
encoding="utf-8",
68+
)
69+
70+
rows = extract_source_records(raw_items, aliases, tmp_path / "events.csv")
71+
72+
assert rows[0]["entity_match_type"] == "direct_beneficiary"
73+
assert rows[0]["match_evidence"] == "Current Company"
74+
assert rows[0]["relationship_type"] == "direct_beneficiary"
75+
76+
77+
def test_generic_context_does_not_become_company_evidence(tmp_path: Path) -> None:
78+
raw_items = tmp_path / "source_items.csv"
79+
raw_items.write_text(
80+
"item_id,published_at,source_type,source_url,author,text\n"
81+
"nist,2026-04-01T00:00:00Z,government_policy,https://www.nist.gov/example,NIST,"
82+
'NIST guidance discusses nuclear reactor safety and tokenization.\n',
83+
encoding="utf-8",
84+
)
85+
aliases = tmp_path / "aliases.csv"
86+
aliases.write_text(
87+
"symbol,name,aliases\nOKLO,Oklo,Oklo|OKLO|nuclear reactor\n"
88+
"COIN,Coinbase,Coinbase|COIN|tokenization\n",
89+
encoding="utf-8",
90+
)
91+
92+
rows = extract_source_records(raw_items, aliases, tmp_path / "events.csv")
93+
94+
assert {row["symbol"] for row in rows} == {"OKLO", "COIN"}
95+
assert {row["entity_match_type"] for row in rows} == {"industry_context"}
96+
97+
98+
def test_canonical_name_is_trusted_only_in_issuer_release(tmp_path: Path) -> None:
99+
raw_items = tmp_path / "source_items.csv"
100+
raw_items.write_text(
101+
"item_id,published_at,source_type,source_url,author,text\n"
102+
"issuer,2026-04-01T00:00:00Z,issuer_release,https://example.com/release,Issuer,"
103+
'Strategy announced a new product.\n'
104+
"policy,2026-04-01T00:00:00Z,government_policy,https://www.nist.gov/example,NIST,"
105+
'Strategy guidance was published.\n',
106+
encoding="utf-8",
107+
)
108+
aliases = tmp_path / "aliases.csv"
109+
aliases.write_text("symbol,name,aliases\nMSTR,Strategy,Strategy|MSTR\n", encoding="utf-8")
110+
111+
rows = extract_source_records(raw_items, aliases, tmp_path / "events.csv")
112+
by_id = {row["event_id"]: row for row in rows}
113+
114+
assert by_id["official-issuer-release-issuer-mstr"]["entity_match_type"] == "issuer"
115+
assert by_id["official-government-policy-policy-mstr"]["entity_match_type"] == "industry_context"
116+
117+
118+
def test_direct_beneficiary_overrides_generic_alias(tmp_path: Path) -> None:
119+
raw_items = tmp_path / "source_items.csv"
120+
raw_items.write_text(
121+
"item_id,published_at,source_type,source_url,author,text\n"
122+
"award,2026-04-01T00:00:00Z,government_procurement,https://www.govinfo.gov/example,Agency,"
123+
'Funding for Strategy was announced.\n',
124+
encoding="utf-8",
125+
)
126+
aliases = tmp_path / "aliases.csv"
127+
aliases.write_text("symbol,name,aliases\nMSTR,Strategy,Strategy|MSTR\n", encoding="utf-8")
128+
129+
rows = extract_source_records(raw_items, aliases, tmp_path / "events.csv")
130+
131+
assert rows[0]["entity_match_type"] == "direct_beneficiary"
132+
133+
134+
def test_known_ticker_collisions_remain_context_only(tmp_path: Path) -> None:
135+
raw_items = tmp_path / "source_items.csv"
136+
raw_items.write_text(
137+
"item_id,published_at,source_type,source_url,author,text\n"
138+
"mstr,2026-04-01T00:00:00Z,government_policy,https://www.nist.gov/mstr,NIST,Strategy guidance\n"
139+
"oklo,2026-04-01T00:00:00Z,government_policy,https://www.nist.gov/oklo,NIST,nuclear reactor safety\n"
140+
"panw,2026-04-01T00:00:00Z,government_policy,https://www.nist.gov/panw,NIST,generic cybersecurity guidance\n"
141+
"coin,2026-04-01T00:00:00Z,government_policy,https://www.federalreserve.gov/coin,Fed,tokenization policy\n"
142+
"intc,2026-04-01T00:00:00Z,government_policy,https://www.commerce.gov/intc,Commerce,CHIPS Act funding\n",
143+
encoding="utf-8",
144+
)
145+
aliases = tmp_path / "aliases.csv"
146+
aliases.write_text(
147+
"symbol,name,aliases\nMSTR,Strategy,Strategy|MSTR\nOKLO,Oklo,Oklo|OKLO|nuclear reactor\n"
148+
"PANW,Palo Alto Networks,Palo Alto Networks|PANW|cybersecurity\n"
149+
"COIN,Coinbase,Coinbase|COIN|tokenization\nINTC,Intel,Intel|INTC|CHIPS Act\n",
150+
encoding="utf-8",
151+
)
152+
153+
rows = extract_source_records(raw_items, aliases, tmp_path / "events.csv")
154+
155+
assert {row["symbol"] for row in rows} == {"MSTR", "OKLO", "PANW", "COIN", "INTC"}
156+
assert {row["entity_match_type"] for row in rows} == {"industry_context"}

0 commit comments

Comments
 (0)