fix(rating): read a decision's rating only from its label

- a decision without a rating label is REVIEW; a lone rating word in its prose may be one it argues against ("not a Sell"), so it is no longer read as the call
This commit is contained in:
Yijia-Xiao
2026-09-29 05:57:18 +00:00
parent 1bf67e35b6
commit 1d92269d35
2 changed files with 18 additions and 22 deletions
+9 -2
View File
@@ -41,8 +41,15 @@ def test_prose_naming_several_ratings_without_a_label_needs_review():
@pytest.mark.unit
def test_prose_naming_one_rating_is_taken_as_the_call():
assert extract_rating("On balance we stay Underweight until margins recover.") == "Underweight"
@pytest.mark.parametrize("prose", [
"We would not Sell here; the dip is a chance to add.",
"**评级**:买入\n\n不建议卖出 (Sell)。", # a label in another language, and a negated Sell
"On balance we stay Underweight until margins recover.",
])
def test_a_rating_word_without_a_label_is_not_read_as_the_call(prose):
"""A rating word in prose may be one the text argues against (#1435)."""
assert extract_rating(prose) is None
assert parse_rating(prose) == RATING_REVIEW
@pytest.mark.unit
+9 -20
View File
@@ -49,20 +49,13 @@ _RATING_LINE_RE = re.compile(
# A line presenting the scale rather than a decision ("Rating Scale: Buy, ...").
_RATING_SCALE_RE = re.compile(r"rating\s*(scale|options|legend)", re.IGNORECASE)
# Standalone 5-tier word anywhere (word boundaries so "Buyer"/"Holding" don't match).
_RATING_WORD_RE = re.compile(
r"\b(" + "|".join(RATINGS_5_TIER) + r")\b", re.IGNORECASE
)
def extract_rating(text: str) -> str | None:
"""Extract a 5-tier rating from prose, or ``None`` if none is present.
"""Extract a 5-tier rating from its label, or ``None`` if there is none.
Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like
``Rating:Overweight`` is matched the same as ASCII):
1. An explicit "Rating: X" label (tolerant of markdown bold): the first one
opening its own line, else the last one anywhere.
2. A single 5-tier rating word, when the text names only one.
Reads an explicit "Rating: X" label (tolerant of markdown bold) in the
NFKC-normalized text, so fullwidth punctuation like ``Rating:Overweight``
matches as ASCII does: the first one opening its own line, else the last
one anywhere.
"""
if not text:
return None
@@ -83,14 +76,10 @@ def extract_rating(text: str) -> str | None:
m = _RATING_LABEL_RE.search(line)
if m and m.group(1).lower() in _RATING_SET:
anywhere = m.group(1).capitalize()
if on_own_line or anywhere:
return on_own_line or anywhere
# No label. A single rating word in the text is the call; several are an
# argument, and picking one of them reports a direction nobody decided --
# prose that rejects a Buy before concluding Underweight read as Buy.
named = {m.group(1).capitalize() for m in _RATING_WORD_RE.finditer(norm)}
return named.pop() if len(named) == 1 else None
# Without a label there is no call to read: a rating word in the prose may be
# one the text argues against ("not a Sell"), and reading it reports a
# direction nobody decided.
return on_own_line or anywhere
def parse_rating(text: str, default: str = RATING_REVIEW) -> str: