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:
3 files changed
+259
-21
No files matched your search
@@ -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,
|
||||
])
|
||||
Reference in new issue
Block a user