Skip to content

Commit 7e984c0

Browse files
Pigbibicodex
andcommitted
fix: fail closed on generic entity matches
Co-Authored-By: Codex <noreply@openai.com>
1 parent 928d4f5 commit 7e984c0

8 files changed

Lines changed: 213 additions & 8 deletions

File tree

docs/free_source_setup.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99
- `data/live/political_watchlist.csv`: initial watchlist for the live publish path.
1010
- `data/live/source_items.csv`: latest raw text pulled by the scheduled RSS pipeline.
1111
- `data/live/source_events.csv`: events extracted deterministically from `source_items.csv`.
12+
13+
`source_events.csv` keeps the original columns and adds the following compatible
14+
evidence columns: `entity_match_type`, `match_evidence`, and
15+
`relationship_type`. Values are `issuer`, `direct_beneficiary`,
16+
`industry_context`, or `unverified`. Only the first two are company-level
17+
evidence; industry vocabulary is retained for auditability but is not scored as
18+
an issuer event. Rows without a verifiable entity match are not emitted by the
19+
live extractor. Legacy imported rows default to `unverified` and therefore fail
20+
closed in downstream company-event scoring.
1221
- `data/live/political_events.csv`: stable Advisor input, refreshed by RSS/source pipeline or maintained after manual review.
1322
- `data/live/source_tracker.csv`: merged watchlist and event tracker.
1423

docs/free_source_setup.zh-CN.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
- `data/live/political_watchlist.csv`:真实发布链路的初始观察池。
1010
- `data/live/source_items.csv`:定时 RSS pipeline 最近一次拉取的公开原始文本。
1111
- `data/live/source_events.csv`:从 `source_items.csv` 确定性抽取出的事件。
12+
13+
`source_events.csv` 保留原有字段,并增加兼容字段:
14+
`entity_match_type``match_evidence``relationship_type`。关系值为
15+
`issuer``direct_beneficiary``industry_context``unverified`
16+
只有前两类可作为公司级证据;行业通用词会保留用于审计,但不会进入公司事件评分。
17+
live 抽取器不会输出无法验证实体关联的行;旧格式导入行默认为
18+
`unverified`,下游公司事件评分会 fail-closed。
1219
- `data/live/political_events.csv`:Advisor 稳定读取的真实事件输入,由 RSS/source pipeline 刷新,也可以人工核验后维护。
1320
- `data/live/source_tracker.csv`:观察池与事件合并后的 tracker。
1421

src/political_event_tracking_research/event_study.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ class Event:
1818
confidence: str
1919
source_url: str
2020
notes: str
21+
entity_match_type: str = "unverified"
22+
match_evidence: str = ""
23+
relationship_type: str = "unverified"
2124

2225

2326
@dataclass(frozen=True)
@@ -74,6 +77,9 @@ def load_events(path: str | Path) -> list[Event]:
7477
confidence=row.get("confidence", ""),
7578
source_url=row.get("source_url", ""),
7679
notes=row.get("notes", ""),
80+
entity_match_type=row.get("entity_match_type", "unverified") or "unverified",
81+
match_evidence=row.get("match_evidence", ""),
82+
relationship_type=row.get("relationship_type", "unverified") or "unverified",
7783
)
7884
)
7985
return events

src/political_event_tracking_research/official_event_import.py

Lines changed: 26 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,10 @@ 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+
if record.entity_match_type not in {"issuer", "direct_beneficiary", "industry_context", "unverified"}:
97+
raise ValueError(f"{record.record_id}: unsupported entity_match_type {record.entity_match_type!r}")
98+
if record.relationship_type not in {"issuer", "direct_beneficiary", "industry_context", "unverified"}:
99+
raise ValueError(f"{record.record_id}: unsupported relationship_type {record.relationship_type!r}")
90100
if record.event_type not in ALLOWED_EVENT_TYPES:
91101
raise ValueError(f"{record.record_id}: unsupported event_type {record.event_type!r}")
92102
if record.source_type in GOVERNMENT_SOURCE_TYPES:
@@ -125,6 +135,9 @@ def normalize_records(records: list[OfficialRecord]) -> list[dict[str, str]]:
125135
"confidence": confidence_for_source(record),
126136
"source_url": record.source_url,
127137
"notes": record.summary,
138+
"entity_match_type": record.entity_match_type,
139+
"match_evidence": record.match_evidence,
140+
"relationship_type": record.relationship_type,
128141
}
129142
)
130143
rows.sort(key=lambda row: (row["event_date"], row["symbol"], row["event_id"]))
@@ -136,7 +149,19 @@ def import_official_events(input_path: str | Path, output_path: str | Path) -> l
136149
rows = normalize_records(records)
137150
write_csv_rows(
138151
output_path,
139-
["event_id", "event_date", "symbol", "event_type", "direction", "confidence", "source_url", "notes"],
152+
[
153+
"event_id",
154+
"event_date",
155+
"symbol",
156+
"event_type",
157+
"direction",
158+
"confidence",
159+
"source_url",
160+
"notes",
161+
"entity_match_type",
162+
"match_evidence",
163+
"relationship_type",
164+
],
140165
rows,
141166
)
142167
return rows

src/political_event_tracking_research/source_mention_extract.py

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

1212

13+
GENERIC_ENTITY_NAMES = frozenset({"strategy"})
14+
15+
1316
@dataclass(frozen=True)
1417
class RawSourceItem:
1518
item_id: str
@@ -24,6 +27,7 @@ class RawSourceItem:
2427
class MentionAlias:
2528
symbol: str
2629
aliases: tuple[str, ...]
30+
name: str = ""
2731

2832

2933
def load_raw_items(path: str | Path) -> list[RawSourceItem]:
@@ -51,10 +55,13 @@ def load_aliases(path: str | Path) -> list[MentionAlias]:
5155
records: list[MentionAlias] = []
5256
for row in read_csv_rows(path):
5357
symbol = row["symbol"].upper()
58+
name = row.get("name", "").strip()
5459
aliases = split_aliases(row.get("aliases", ""))
60+
if name and name.casefold() not in {alias.casefold() for alias in aliases}:
61+
aliases = (name, *aliases)
5562
if symbol not in aliases:
5663
aliases = (symbol, *aliases)
57-
records.append(MentionAlias(symbol=symbol, aliases=aliases))
64+
records.append(MentionAlias(symbol=symbol, aliases=aliases, name=name))
5865
return records
5966

6067

@@ -88,6 +95,36 @@ def match_symbols(text: str, aliases: list[MentionAlias]) -> list[str]:
8895
return sorted(dict.fromkeys(matches))
8996

9097

98+
def _is_generic_alias(alias_record: MentionAlias, alias: str) -> bool:
99+
normalized = normalize_match_text(alias).strip()
100+
if normalized.upper() == alias_record.symbol.upper():
101+
return False
102+
if normalized.casefold() == alias_record.name.casefold():
103+
return normalized.casefold() in GENERIC_ENTITY_NAMES
104+
if alias_record.name:
105+
return True
106+
# Lower-case phrases are intentionally treated as industry vocabulary, not entities.
107+
return normalized == normalized.lower() or len(normalized.split()) >= 3 and not normalized.istitle()
108+
109+
110+
def match_evidence(text: str, alias_record: MentionAlias) -> tuple[str, str] | None:
111+
normalized_text = normalize_match_text(text)
112+
for alias in alias_record.aliases:
113+
if not alias_pattern(alias).search(normalized_text):
114+
continue
115+
if _is_generic_alias(alias_record, alias):
116+
return "industry_context", normalize_match_text(alias)
117+
relationship = "issuer"
118+
if re.search(
119+
rf"(?:awarded to|contract with|funding for|benefit(?:s|ed)? from)\s+(?:the\s+)?{re.escape(normalize_match_text(alias))}",
120+
normalized_text,
121+
re.IGNORECASE,
122+
):
123+
relationship = "direct_beneficiary"
124+
return relationship, normalize_match_text(alias)
125+
return None
126+
127+
91128
def infer_event_type(item: RawSourceItem) -> str:
92129
source_type = item.source_type
93130
text = item.text.lower()
@@ -133,24 +170,42 @@ def extract_source_records(raw_items_path: str | Path, aliases_path: str | Path,
133170
aliases = load_aliases(aliases_path)
134171
records: list[OfficialRecord] = []
135172
for item in raw_items:
136-
symbols = match_symbols(item.text, aliases)
137-
for symbol in symbols:
173+
for alias_record in aliases:
174+
evidence = match_evidence(item.text, alias_record)
175+
if evidence is None:
176+
continue
177+
entity_match_type, matched_text = evidence
138178
records.append(
139179
OfficialRecord(
140-
record_id=f"{item.item_id}-{symbol.lower()}",
180+
record_id=f"{item.item_id}-{alias_record.symbol.lower()}",
141181
record_date=item_date(item),
142-
symbol=symbol,
182+
symbol=alias_record.symbol,
143183
source_type=item.source_type,
144184
event_type=infer_event_type(item),
145185
direction=infer_direction(item.text),
146186
source_url=item.source_url,
147187
summary=f"{item.author}: {item.text}".strip(": "),
188+
entity_match_type=entity_match_type,
189+
match_evidence=matched_text,
190+
relationship_type=entity_match_type,
148191
)
149192
)
150193
rows = normalize_records(records)
151194
write_csv_rows(
152195
output_path,
153-
["event_id", "event_date", "symbol", "event_type", "direction", "confidence", "source_url", "notes"],
196+
[
197+
"event_id",
198+
"event_date",
199+
"symbol",
200+
"event_type",
201+
"direction",
202+
"confidence",
203+
"source_url",
204+
"notes",
205+
"entity_match_type",
206+
"match_evidence",
207+
"relationship_type",
208+
],
154209
rows,
155210
)
156211
return rows

src/political_event_tracking_research/tracker.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ def latest_event(events: list[Event]) -> Event | None:
6767
def event_score(events: list[Event]) -> int:
6868
score = 0
6969
for event in events:
70+
if event.entity_match_type not in {"issuer", "direct_beneficiary"}:
71+
continue
7072
score += EVENT_WEIGHTS.get(event.event_type, 0)
7173
score += CONFIDENCE_WEIGHTS.get(event.confidence, 0)
7274
return score

tests/test_source_mention_extract.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
from pathlib import Path
44

5-
from political_event_tracking_research.source_mention_extract import extract_source_records, infer_direction, match_symbols
5+
from political_event_tracking_research.source_mention_extract import (
6+
extract_source_records,
7+
infer_direction,
8+
match_symbols,
9+
)
610
from political_event_tracking_research.source_mention_extract import MentionAlias
711

812

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

tests/test_tracker.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,12 @@ def test_tracker_prioritizes_disclosure_plus_mention() -> None:
3838
assert rows[0]["latest_event_date"] == "2026-01-02"
3939
assert rows[1]["symbol"] == "BBB"
4040
assert rows[1]["trigger_status"] == "watchlist"
41+
42+
43+
def test_tracker_does_not_score_industry_context_as_company_evidence() -> None:
44+
item = WatchlistItem("AAA", "Alpha", "watchlist", "watchlist", "seed", "https://example.com")
45+
events = [Event("e1", date(2026, 1, 1), "AAA", "public_mention", "bullish", "high", "https://example.com", "", "industry_context", "cybersecurity", "industry_context")]
46+
47+
rows = build_tracker_rows([item], events)
48+
49+
assert rows[0]["priority_score"] == 0

0 commit comments

Comments
 (0)