feat(sentiment): structured output for the Sentiment Analyst

The analyst emitted free-form prose, so its sentiment header varied by
provider and run and downstream consumers needed drifting regex. Extend
the structured-output pattern the trio already uses: a SentimentReport
schema (band + 0-10 score + confidence + narrative) rendered to a
deterministic header, with a free-text fallback for providers that lack
native structured output.

#796
This commit is contained in:
Yijia-Xiao committed 2026-05-31 01:45:25 +00:00
1 parent a66aa8fb94
commit e80636fc0e
3 files changed
+259 -21

No files matched your search

+90 -1
View File
@@ -19,7 +19,7 @@ so that:
from __future__ import annotations
from enum import Enum
from typing import Optional
from typing import Literal, Optional
from pydantic import BaseModel, Field
@@ -226,3 +226,92 @@ def render_pm_decision(decision: PortfolioDecision) -> str:
if decision.time_horizon:
parts.extend(["", f"**Time Horizon**: {decision.time_horizon}"])
return "\n".join(parts)
# ---------------------------------------------------------------------------
# Sentiment Analyst
# ---------------------------------------------------------------------------
class SentimentBand(str, Enum):
"""Discrete sentiment direction produced by the Sentiment Analyst.
Six tiers keep the signal granular enough to be actionable while remaining
small enough for every provider to map reliably from its JSON output.
"""
BULLISH = "Bullish"
MILDLY_BULLISH = "Mildly Bullish"
NEUTRAL = "Neutral"
MIXED = "Mixed"
MILDLY_BEARISH = "Mildly Bearish"
BEARISH = "Bearish"
class SentimentReport(BaseModel):
"""Structured sentiment report produced by the Sentiment Analyst.
Replaces the previous free-form prose output so downstream consumers
(dashboards, audit logs, PDF renderers, other agents) can read
``overall_band`` and ``overall_score`` without maintaining fragile regex
fallbacks that drift with every model release. ``narrative`` preserves the
rich source-by-source analysis; ``render_sentiment_report`` prepends a
deterministic header so the saved report stays human-readable.
"""
overall_band: SentimentBand = Field(
description=(
"Overall sentiment direction. Exactly one of: "
"Bullish / Mildly Bullish / Neutral / Mixed / Mildly Bearish / Bearish. "
"Use Mixed when sources point in clearly different directions. "
"Use Neutral only when all sources are genuinely silent or non-committal."
),
)
overall_score: float = Field(
ge=0.0,
le=10.0,
description=(
"Numeric sentiment intensity on a 0–10 scale. "
"0 = maximally bearish, 5 = neutral, 10 = maximally bullish. "
"Guideline for consistency with overall_band: "
"Bullish ~6.5–10, Mildly Bullish ~5.5–6.4, Neutral/Mixed ~4.5–5.5, "
"Mildly Bearish ~3.5–4.4, Bearish ~0–3.4. "
"Only the 0–10 bounds are enforced."
),
)
confidence: Literal["low", "medium", "high"] = Field(
description=(
"Confidence in the assessment based on data quality and sample size. "
"Use 'low' when one or more sources returned a placeholder or fewer "
"than 5 data points; 'medium' when data is present but sparse; "
"'high' when all three sources returned substantive data."
),
)
narrative: str = Field(
description=(
"Full sentiment report covering, in order: "
"(1) source-by-source breakdown with specific evidence (cite message "
"counts, ratios, notable posts); "
"(2) cross-source divergences and alignments; "
"(3) dominant narrative themes; "
"(4) catalysts and risks surfaced by the data; "
"(5) a markdown table summarising key sentiment signals, their "
"direction, source, and supporting evidence."
),
)
def render_sentiment_report(report: SentimentReport) -> str:
"""Render a SentimentReport to the markdown shape the rest of the system expects.
The structured header (band + score + confidence) is prepended to the
narrative so the saved report is both human-readable and machine-parseable
without regex.
"""
return "\n".join([
f"**Overall Sentiment:** **{report.overall_band.value}** "
f"(Score: {report.overall_score:.1f}/10)",
f"**Confidence:** {report.confidence.capitalize()}",
"",
report.narrative,
])